diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Timeline.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Timeline.swift index 27787654..3be1b6e0 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Timeline.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Timeline.swift @@ -8,7 +8,7 @@ import Foundation -public enum Timeline: Equatable { +public enum Timeline: Equatable, Hashable { case home case `public`(local: Bool) case tag(hashtag: String) diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 43e3b201..c892f6a1 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -52,7 +52,7 @@ D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; }; D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; }; D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */; }; - D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759829384D4D00C0B37F /* FiltersView.swift */; }; + D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */; }; D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759A29384F9C00C0B37F /* FilterMO.swift */; }; D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759C2938574B00C0B37F /* FilterRow.swift */; }; D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */; }; @@ -186,6 +186,10 @@ D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; }; D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; }; D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DE828D962C2006341DA /* TimelineLikeController.swift */; }; + D68A76DA29511CA6001DA1B3 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */; }; + D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E229524D2A001DA1B3 /* ListMO.swift */; }; + D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */; }; + D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */; }; D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; }; D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; }; D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; }; @@ -424,7 +428,7 @@ D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = ""; }; D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = ""; }; D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = ""; }; - D61F759829384D4D00C0B37F /* FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = ""; }; + D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeTimelinesView.swift; sourceTree = ""; }; D61F759A29384F9C00C0B37F /* FilterMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMO.swift; sourceTree = ""; }; D61F759C2938574B00C0B37F /* FilterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterRow.swift; sourceTree = ""; }; D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparator.swift; sourceTree = ""; }; @@ -560,6 +564,10 @@ D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = ""; }; D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = ""; }; D6895DE828D962C2006341DA /* TimelineLikeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeController.swift; sourceTree = ""; }; + D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPreferences.swift; sourceTree = ""; }; + D68A76E229524D2A001DA1B3 /* ListMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListMO.swift; sourceTree = ""; }; + D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelinesView.swift; sourceTree = ""; }; + D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddHashtagPinnedTimelineView.swift; sourceTree = ""; }; D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = ""; }; D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = ""; }; D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = ""; }; @@ -798,14 +806,16 @@ path = "Instance Cell"; sourceTree = ""; }; - D61F759729384D4200C0B37F /* Filters */ = { + D61F759729384D4200C0B37F /* Customize Timelines */ = { isa = PBXGroup; children = ( - D61F759829384D4D00C0B37F /* FiltersView.swift */, + D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */, D61F759C2938574B00C0B37F /* FilterRow.swift */, D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */, + D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */, + D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */, ); - path = Filters; + path = "Customize Timelines"; sourceTree = ""; }; D623A53B2635F4E20095BD04 /* Poll */ = { @@ -895,6 +905,8 @@ D61F759A29384F9C00C0B37F /* FilterMO.swift */, D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */, D6D706A62948D4D0000827ED /* TimlineState.swift */, + D68A76E229524D2A001DA1B3 /* ListMO.swift */, + D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, ); @@ -924,7 +936,7 @@ D6F2E960249E772F005846BB /* Crash Reporter */, D627943C23A5635D00D38C68 /* Explore */, D6A4DCC92553666600D9DE31 /* Fast Account Switcher */, - D61F759729384D4200C0B37F /* Filters */, + D61F759729384D4200C0B37F /* Customize Timelines */, D641C788213DD86D004B4513 /* Large Image */, D627944B23A9A02400D38C68 /* Lists */, D641C782213DD7F0004B4513 /* Main */, @@ -1871,6 +1883,7 @@ D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, + D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */, D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */, D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */, D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */, @@ -1979,6 +1992,7 @@ D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */, + D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */, D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */, D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */, D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */, @@ -2006,7 +2020,7 @@ D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */, - D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */, + D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */, D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */, D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, @@ -2040,6 +2054,7 @@ D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */, D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */, D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */, + D68A76DA29511CA6001DA1B3 /* AccountPreferences.swift in Sources */, D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */, D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */, D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */, @@ -2073,6 +2088,7 @@ D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */, D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */, D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */, + D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */, D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */, D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */, D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */, diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 32e7419b..4af44b46 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -40,6 +40,7 @@ class MastodonController: ObservableObject { let instanceURL: URL var accountInfo: LocalData.UserAccountInfo? + var accountPreferences: AccountPreferences! let client: Client! @@ -154,6 +155,13 @@ class MastodonController: ObservableObject { // are available when Filterers are constructed loadCachedFilters() + if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first { + accountPreferences = existing + } else { + accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext) + persistentContainer.save(context: persistentContainer.viewContext) + } + Task { do { async let ownAccount = try getOwnAccount() diff --git a/Tusker/CoreData/AccountPreferences.swift b/Tusker/CoreData/AccountPreferences.swift new file mode 100644 index 00000000..ebce929c --- /dev/null +++ b/Tusker/CoreData/AccountPreferences.swift @@ -0,0 +1,35 @@ +// +// AccountPreferences.swift +// Tusker +// +// Created by Shadowfacts on 12/19/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData +import Pachyderm + +@objc(AccountPreferences) +public final class AccountPreferences: NSManagedObject { + + @nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest { + let req = NSFetchRequest(entityName: "AccountPreferences") + req.predicate = NSPredicate(format: "accountID = %@", account.id) + return req + } + + @NSManaged public var accountID: String + @NSManaged var pinnedTimelinesData: Data? + + @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: []) + var pinnedTimelines: [Timeline] + + static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { + let prefs = AccountPreferences(context: context) + prefs.accountID = account.id + prefs.pinnedTimelines = [.home, .public(local: true), .public(local: false)] + return prefs + } + +} diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index f6ff2009..4282f7c3 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -28,6 +28,10 @@ + + + + @@ -121,6 +125,7 @@ + diff --git a/Tusker/Extensions/Timline+UI.swift b/Tusker/Extensions/Timline+UI.swift index a0364187..02104ce0 100644 --- a/Tusker/Extensions/Timline+UI.swift +++ b/Tusker/Extensions/Timline+UI.swift @@ -25,18 +25,22 @@ extension Timeline { } } - var tabBarImage: UIImage? { + var image: UIImage { switch self { case .home: - return UIImage(systemName: "house.fill") + return UIImage(systemName: "house.fill")! case let .public(local): if local { - return UIImage(systemName: "person.and.person.fill") + return UIImage(systemName: "person.and.person.fill")! } else { - return UIImage(systemName: "globe") + return UIImage(systemName: "globe")! } - default: - return nil + case .list(id: _): + return UIImage(systemName: "list.bullet")! + case .tag(hashtag: _): + return UIImage(systemName: "number")! + case .direct: + return UIImage(systemName: "enveloep.fill")! } } diff --git a/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift b/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift new file mode 100644 index 00000000..57a28421 --- /dev/null +++ b/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift @@ -0,0 +1,113 @@ +// +// AddHashtagPinnedTimelineView.swift +// Tusker +// +// Created by Shadowfacts on 12/20/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm + +struct AddHashtagPinnedTimelineView: View { + @EnvironmentObject private var mastodonController: MastodonController + @Environment(\.dismiss) private var dismiss + + @Binding var pinnedTimelines: [Timeline] + @StateObject private var viewModel = SearchViewModel() + @State private var searchTask: Task? + @State private var isSearching = false + @State private var searchResults: [String] = [] + + private var savedAndFollowedHashtags: [String] { + var tags = Set() + let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!) + for saved in (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [] { + tags.insert(saved.name) + } + for followed in mastodonController.followedHashtags { + tags.insert(followed.name) + } + return Array(tags).sorted(using: SemiCaseSensitiveComparator()) + } + + var body: some View { + NavigationView { + list + .navigationTitle("Search") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $viewModel.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Search for hashtags")) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + .navigationViewStyle(.stack) + .onReceive(viewModel.$searchQuery, perform: { newValue in + isSearching = !newValue.isEmpty + }) + .onReceive(viewModel.$searchQuery.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main), perform: { _ in + searchTask?.cancel() + searchTask = Task { + try? await updateSearchResults() + } + }) + } + + private var list: some View { + List { + Section { + if viewModel.searchQuery.isEmpty { + forEachTag(savedAndFollowedHashtags) + } else { + forEachTag(searchResults) + } + } header: { + ProgressView() + .progressViewStyle(.circular) + .opacity(isSearching ? 1 : 0) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(EmptyView()) + .listRowSeparator(.hidden) + } + } + .listStyle(.grouped) + } + + private func forEachTag(_ tags: [String]) -> some View { + ForEach(tags, id: \.self) { tag in + Button { + pinnedTimelines.append(.tag(hashtag: tag)) + dismiss() + } label: { + Text("#\(tag)") + } + .tint(.primary) + .disabled(pinnedTimelines.contains(.tag(hashtag: tag))) + } + } + + private func updateSearchResults() async throws { + guard !viewModel.searchQuery.isEmpty else { + return + } + isSearching = true + let req = Client.search(query: viewModel.searchQuery, types: [.hashtags]) + let (results, _) = try await mastodonController.run(req) + searchResults = results.hashtags.map(\.name) + isSearching = false + } +} + +private class SearchViewModel: ObservableObject { + @Published var searchQuery = "" +} + +//struct AddHashtagPinnedTimelineView_Previews: PreviewProvider { +// static var previews: some View { +// AddHashtagPinnedTimelineView() +// } +//} diff --git a/Tusker/Screens/Filters/FiltersView.swift b/Tusker/Screens/Customize Timelines/CustomizeTimelinesView.swift similarity index 72% rename from Tusker/Screens/Filters/FiltersView.swift rename to Tusker/Screens/Customize Timelines/CustomizeTimelinesView.swift index cbdc0bbf..f017f8eb 100644 --- a/Tusker/Screens/Filters/FiltersView.swift +++ b/Tusker/Screens/Customize Timelines/CustomizeTimelinesView.swift @@ -1,5 +1,5 @@ // -// FiltersView.swift +// CustomizeTimelinesView.swift // Tusker // // Created by Shadowfacts on 11/30/22. @@ -9,18 +9,18 @@ import SwiftUI import Pachyderm -struct FiltersView: View { +struct CustomizeTimelinesView: View { let mastodonController: MastodonController var body: some View { - FiltersList() + CustomizeTimelinesList() .environmentObject(mastodonController) .environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext) } } -struct FiltersList: View { +struct CustomizeTimelinesList: View { @EnvironmentObject private var mastodonController: MastodonController @ObservedObject private var preferences = Preferences.shared @FetchRequest(sortDescriptors: []) private var filters: FetchedResults @@ -50,6 +50,8 @@ struct FiltersList: View { private var navigationBody: some View { List { + PinnedTimelinesView(accountPreferences: mastodonController.accountPreferences) + Section { Toggle(isOn: $preferences.hideReblogsInTimelines) { Text("Hide Reblogs") @@ -62,18 +64,27 @@ struct FiltersList: View { } Section { + filtersForEach(unexpiredFilters) + NavigationLink { EditFilterView(filter: EditedFilter(), create: true, originallyExpired: false) } label: { Label("Add Filter", systemImage: "plus") .foregroundColor(.accentColor) } + } header: { + Text("Active Filters") } - filtersSection(unexpiredFilters, header: Text("Active")) - filtersSection(expiredFilters, header: Text("Expired")) + if !expiredFilters.isEmpty { + Section { + filtersForEach(expiredFilters) + } header: { + Text("Expired Filters") + } + } } - .navigationTitle(Text("Filters")) + .navigationTitle(Text("Customize Timelines")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -95,30 +106,26 @@ struct FiltersList: View { } @ViewBuilder - private func filtersSection(_ filters: [FilterMO], header: some View) -> some View { + private func filtersForEach(_ filters: [FilterMO]) -> some View { if !filters.isEmpty { - Section { - ForEach(filters, id: \.id) { filter in - NavigationLink { - EditFilterView(filter: EditedFilter(filter), create: false, originallyExpired: filter.expiresAt != nil && filter.expiresAt! <= Date()) - } label: { - FilterRow(filter: filter) - } - .contextMenu { - Button(role: .destructive) { - deleteFilter(filter) - } label: { - Label("Delete Filter", systemImage: "trash") - } - } + ForEach(filters, id: \.id) { filter in + NavigationLink { + EditFilterView(filter: EditedFilter(filter), create: false, originallyExpired: filter.expiresAt != nil && filter.expiresAt! <= Date()) + } label: { + FilterRow(filter: filter) } - .onDelete { indices in - for filter in indices.map({ filters[$0] }) { + .contextMenu { + Button(role: .destructive) { deleteFilter(filter) + } label: { + Label("Delete Filter", systemImage: "trash") } } - } header: { - header + } + .onDelete { indices in + for filter in indices.map({ filters[$0] }) { + deleteFilter(filter) + } } } } diff --git a/Tusker/Screens/Filters/EditFilterView.swift b/Tusker/Screens/Customize Timelines/EditFilterView.swift similarity index 98% rename from Tusker/Screens/Filters/EditFilterView.swift rename to Tusker/Screens/Customize Timelines/EditFilterView.swift index a41d2423..4b1bc535 100644 --- a/Tusker/Screens/Filters/EditFilterView.swift +++ b/Tusker/Screens/Customize Timelines/EditFilterView.swift @@ -108,7 +108,6 @@ struct EditFilterView: View { } } - Toggle("Expires", isOn: expires) if expires.wrappedValue { @@ -143,7 +142,7 @@ struct EditFilterView: View { Text("Contexts") } } - .navigationTitle("Edit Filter") + .navigationTitle(create ? "Add Filter" : "Edit Filter") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -151,7 +150,7 @@ struct EditFilterView: View { ProgressView() .progressViewStyle(.circular) } else { - Button(create ? "Create" : "Save") { + Button("Save") { saveFilter() } .disabled(!filter.isValid(for: mastodonController) || (!edited && originallyExpired)) diff --git a/Tusker/Screens/Filters/FilterRow.swift b/Tusker/Screens/Customize Timelines/FilterRow.swift similarity index 100% rename from Tusker/Screens/Filters/FilterRow.swift rename to Tusker/Screens/Customize Timelines/FilterRow.swift diff --git a/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift b/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift new file mode 100644 index 00000000..ae176dd4 --- /dev/null +++ b/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift @@ -0,0 +1,130 @@ +// +// PinnedTimelinesView.swift +// Tusker +// +// Created by Shadowfacts on 12/20/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm + +struct PinnedTimelinesView: View { + @EnvironmentObject private var mastodonController: MastodonController + @ObservedObject private var accountPreferences: AccountPreferences + + @State private var isShowingAddHashtagSheet = false + // store this separately from AccountPreferences in the view, b/c the @LazilyDecoding wrapper breaks animations + @State private var pinnedTimelines: [Timeline] + + init(accountPreferences: AccountPreferences) { + self.accountPreferences = accountPreferences + self.pinnedTimelines = accountPreferences.pinnedTimelines + } + + var body: some View { + Section { + ForEach(pinnedTimelines, id: \.id) { timeline in + HStack { + Label { + if case .list(id: let id) = timeline, + let list = mastodonController.lists.first(where: { $0.id == id }) { + Text(list.title) + } else if case .tag(hashtag: let tag) = timeline { + Text(tag) + } else { + Text(timeline.title) + } + } icon: { + Image(uiImage: timeline.image.withRenderingMode(.alwaysTemplate)) + } + + Spacer() + + Image(systemName: "line.3.horizontal") + .foregroundColor(Color(.lightGray)) + .accessibilityHidden(true) + } + } + .onMove { indices, newOffset in + pinnedTimelines.move(fromOffsets: indices, toOffset: newOffset) + } + .onDelete { indices in + pinnedTimelines.remove(atOffsets: indices) + } + + Menu { + ForEach([Timeline.home, .public(local: true), .public(local: false)], id: \.id) { timeline in + Button { + withAnimation { + pinnedTimelines.append(timeline) + } + } label: { + Label { + Text(timeline.title) + } icon: { + Image(uiImage: timeline.image) + } + } + .disabled(pinnedTimelines.contains(timeline)) + } + + Menu("List…") { + ForEach(mastodonController.lists, id: \.id) { list in + Button { + withAnimation { + pinnedTimelines.append(list.timeline) + } + } label: { + Text(list.title) + } + .disabled(pinnedTimelines.contains(list.timeline)) + } + } + + Button { + isShowingAddHashtagSheet = true + } label: { + Label("Hashtag…", systemImage: "number") + } + } label: { + Label("Add…", systemImage: "plus") + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } header: { + Text("Pinned Timelines") + } + .sheet(isPresented: $isShowingAddHashtagSheet, content: { + AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines) + }) + .onReceive(accountPreferences.publisher(for: \.pinnedTimelinesData)) { _ in + if pinnedTimelines != accountPreferences.pinnedTimelines { + pinnedTimelines = accountPreferences.pinnedTimelines + } + } + .onChange(of: pinnedTimelines) { newValue in + if accountPreferences.pinnedTimelines != newValue { + accountPreferences.pinnedTimelines = newValue + } + } + } +} + +fileprivate extension Timeline { + var id: String { + switch self { + case .home: + return "home" + case .public(local: let local): + return "public:\(local)" + case .list(id: let id): + return "list:\(id)" + case .tag(hashtag: let tag): + return "tag:\(tag)" + case .direct: + return "direct" + } + } +} diff --git a/Tusker/Screens/Notifications/NotificationsPageViewController.swift b/Tusker/Screens/Notifications/NotificationsPageViewController.swift index 88fc4675..1819143e 100644 --- a/Tusker/Screens/Notifications/NotificationsPageViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsPageViewController.swift @@ -11,9 +11,6 @@ import Pachyderm class NotificationsPageViewController: SegmentedPageViewController { - private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title") - private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title") - weak var mastodonController: MastodonController! var initialMode: NotificationsMode? @@ -22,20 +19,14 @@ class NotificationsPageViewController: SegmentedPageViewController { @@ -17,33 +18,27 @@ class TimelinesPageViewController: SegmentedPageViewController NSUserActivity? { - return (pageControllers[currentIndex] as! TimelineViewController).stateRestorationActivity() + return (currentViewController as! TimelineViewController).stateRestorationActivity() } func restoreActivity(_ activity: NSUserActivity) { guard let timeline = UserActivityManager.getTimeline(from: activity) else { return } - let page: Page - switch timeline { - case .home: - page = .home - case .public(local: false): - page = .federated - case .public(local: true): - page = .local - default: - return - } + let page = Page(mastodonController: mastodonController, timeline: timeline) selectPage(page, animated: false) } - @objc private func filtersPressed() { - present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true) + @objc private func customizePressed() { + present(UIHostingController(rootView: CustomizeTimelinesView(mastodonController: mastodonController)), animated: true) } - enum Page: Hashable { - case home - case local - case federated - } - +} + +extension TimelinesPageViewController { + struct Page: SegmentedPageViewControllerPage { + let mastodonController: MastodonController + let timeline: Timeline + + static func ==(lhs: Page, rhs: Page) -> Bool { + return lhs.timeline == rhs.timeline + } + + func hash(into hasher: inout Hasher) { + hasher.combine(timeline) + } + + var segmentedControlTitle: String { + if case let .list(id) = timeline, + let list = try? mastodonController.persistentContainer.viewContext.fetch(ListMO.fetchRequest(id: id)).first { + return list.title + } else { + return timeline.title + } + } + } } diff --git a/Tusker/Screens/Utilities/SegmentedPageViewController.swift b/Tusker/Screens/Utilities/SegmentedPageViewController.swift index d55319e5..dd44ff92 100644 --- a/Tusker/Screens/Utilities/SegmentedPageViewController.swift +++ b/Tusker/Screens/Utilities/SegmentedPageViewController.swift @@ -8,35 +8,39 @@ import UIKit -class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController { +protocol SegmentedPageViewControllerPage: Hashable { + var segmentedControlTitle: String { get } +} - let pages: [Page] - let pageControllers: [UIViewController] +class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController { + + private(set) var pages: [Page]! + private let pageProvider: (Page) -> UIViewController + private var pageControllers = [Page: UIViewController]() private var initialPage: Page private var currentPage: Page - var currentIndex: Int { - pages.firstIndex(of: currentPage)! + var currentIndex: Int! { + pages.firstIndex(of: currentPage) + } + var currentViewController: UIViewController { + viewControllers!.first! } let segmentedControl = ScrollingSegmentedControl() - init(pages: [(Page, String, UIViewController)]) { + init(pages: [Page], pageProvider: @escaping (Page) -> UIViewController) { precondition(!pages.isEmpty) - self.pages = pages.map(\.0) - self.pageControllers = pages.map(\.2) + self.pageProvider = pageProvider - initialPage = self.pages.first! - currentPage = self.pages.first! + initialPage = pages.first! + currentPage = pages.first! super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) - // this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView - // before the view has necessarily loaded - segmentedControl.options = pages.map { - .init(value: $0.0, name: $0.1) - } + setPages(pages, animated: false) + segmentedControl.didSelectOption = { [unowned self] option in if let option { self.selectPage(option, animated: true) @@ -54,6 +58,26 @@ class SegmentedPageViewController: UIPageViewController, UIPageV fatalError("init(coder:) has not been implemented") } + func setPages(_ pages: [Page], animated: Bool) { + precondition(!pages.isEmpty) + + self.pages = pages + + if !pages.contains(currentPage) { + selectPage(pages.first!, animated: animated) + } + + for key in pageControllers.keys where !pages.contains(key) { + pageControllers.removeValue(forKey: key) + } + + // this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView + // before the view has necessarily loaded + segmentedControl.options = pages.map { + .init(value: $0, name: $0.segmentedControlTitle) + } + } + override func viewDidLoad() { super.viewDidLoad() @@ -80,12 +104,23 @@ class SegmentedPageViewController: UIPageViewController, UIPageV initialPage = page return } - let prevIndex = currentIndex - currentPage = page - let index = pages.firstIndex(of: page)! - let newController = pageControllers[index] + let direction: UIPageViewController.NavigationDirection + if let prevIndex = currentIndex { + let index = pages.firstIndex(of: page)! + direction = index - prevIndex > 0 ? .forward : .reverse + } else { + direction = .forward + } + + currentPage = page + let newController: UIViewController + if let existing = pageControllers[page] { + newController = existing + } else { + newController = pageProvider(page) + pageControllers[page] = newController + } - let direction: UIPageViewController.NavigationDirection = index - prevIndex > 0 ? .forward : .reverse setViewControllers([newController], direction: direction, animated: animated) navigationItem.title = newController.title @@ -108,7 +143,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageV extension SegmentedPageViewController: TabBarScrollableViewController { func tabBarScrollToTop() { - if let scrollableVC = pageControllers[currentIndex] as? TabBarScrollableViewController { + if let scrollableVC = currentViewController as? TabBarScrollableViewController { scrollableVC.tabBarScrollToTop() } } @@ -116,7 +151,7 @@ extension SegmentedPageViewController: TabBarScrollableViewController { extension SegmentedPageViewController: BackgroundableViewController { func sceneDidEnterBackground() { - if let current = pageControllers[currentIndex] as? BackgroundableViewController { + if let current = currentViewController as? BackgroundableViewController { current.sceneDidEnterBackground() } } @@ -124,7 +159,7 @@ extension SegmentedPageViewController: BackgroundableViewController { extension SegmentedPageViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { - if let current = pageControllers[currentIndex] as? StatusBarTappableViewController { + if let current = currentViewController as? StatusBarTappableViewController { return current.handleStatusBarTapped(xPosition: xPosition) } return .continue diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index f457131b..842b0446 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -216,23 +216,11 @@ class UserActivityManager { return } - switch timeline { - case .home, .public(true), .public(false): + if mastodonController.accountPreferences.pinnedTimelines.contains(timeline) { navigationController.popToRootViewController(animated: false) let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController - let page: TimelinesPageViewController.Page - switch timeline { - case .home: - page = .home - case .public(local: false): - page = .federated - case .public(local: true): - page = .local - default: - fatalError() - } - rootController.selectPage(page, animated: false) - default: + rootController.selectTimeline(timeline, animated: false) + } else { let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController) navigationController.pushViewController(timeline, animated: false) } diff --git a/Tusker/Views/ScrollingSegmentedControl.swift b/Tusker/Views/ScrollingSegmentedControl.swift index ab367544..7dace042 100644 --- a/Tusker/Views/ScrollingSegmentedControl.swift +++ b/Tusker/Views/ScrollingSegmentedControl.swift @@ -89,6 +89,9 @@ class ScrollingSegmentedControl: UIScrollView, UIGestureRecogni label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)" optionsStack.addArrangedSubview(label) } + + updateSelectedIndicatorView() + invalidateIntrinsicContentSize() } func setSelectedOption(_ value: Value, animated: Bool) {