diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 4b3c04b3..1b5680f3 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -20,6 +20,9 @@ D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D60088EE2980D8B5005B4D00 /* StoreKit.framework */; }; D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60088F12980DAA0005B4D00 /* TipJarView.swift */; }; D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60089182981FEBA005B4D00 /* ConfettiView.swift */; }; + D600891B29848289005B4D00 /* PinnedTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600891A29848289005B4D00 /* PinnedTimeline.swift */; }; + D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */; }; + D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */; }; D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */; }; D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */; }; D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */; }; @@ -422,6 +425,9 @@ D60088F02980D938005B4D00 /* Tusker.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tusker.storekit; sourceTree = ""; }; D60088F12980DAA0005B4D00 /* TipJarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipJarView.swift; sourceTree = ""; }; D60089182981FEBA005B4D00 /* ConfettiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiView.swift; sourceTree = ""; }; + D600891A29848289005B4D00 /* PinnedTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimeline.swift; sourceTree = ""; }; + D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelineTests.swift; sourceTree = ""; }; + D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddInstancePinnedTimelineView.swift; sourceTree = ""; }; D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = ""; }; D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCollectionViewController.swift; sourceTree = ""; }; D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = ""; }; @@ -852,6 +858,7 @@ D627FF75217E923E00CC0648 /* DraftsManager.swift */, D61F75AE293AF50C00C0B37F /* EditedFilter.swift */, D65B4B532971F71D00DABDFB /* EditedReport.swift */, + D600891A29848289005B4D00 /* PinnedTimeline.swift */, ); path = Models; sourceTree = ""; @@ -873,6 +880,7 @@ D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */, D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */, D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */, + D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */, ); path = "Customize Timelines"; sourceTree = ""; @@ -1561,6 +1569,7 @@ D6114E1627F8BB210080E273 /* VersionTests.swift */, D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */, D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */, + D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */, D6D4DDE6212518A200E1C4BB /* Info.plist */, ); path = TuskerTests; @@ -2031,6 +2040,7 @@ D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */, D61DC84628F498F200B82C6E /* Logging.swift in Sources */, D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */, + D600891B29848289005B4D00 /* PinnedTimeline.swift in Sources */, D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */, D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */, D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */, @@ -2038,6 +2048,7 @@ D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */, + D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */, D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */, @@ -2228,6 +2239,7 @@ D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */, D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */, D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */, + D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */, D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */, D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */, D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */, diff --git a/Tusker/CoreData/AccountPreferences.swift b/Tusker/CoreData/AccountPreferences.swift index 2f648380..bdc84472 100644 --- a/Tusker/CoreData/AccountPreferences.swift +++ b/Tusker/CoreData/AccountPreferences.swift @@ -25,7 +25,7 @@ public final class AccountPreferences: NSManagedObject { @NSManaged var pinnedTimelinesData: Data? @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: []) - var pinnedTimelines: [Timeline] + var pinnedTimelines: [PinnedTimeline] static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { let prefs = AccountPreferences(context: context) diff --git a/Tusker/Extensions/Timline+UI.swift b/Tusker/Extensions/Timline+UI.swift index 02104ce0..ca427405 100644 --- a/Tusker/Extensions/Timline+UI.swift +++ b/Tusker/Extensions/Timline+UI.swift @@ -25,23 +25,4 @@ extension Timeline { } } - var image: UIImage { - switch self { - case .home: - return UIImage(systemName: "house.fill")! - case let .public(local): - if local { - return UIImage(systemName: "person.and.person.fill")! - } else { - return UIImage(systemName: "globe")! - } - 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/Models/PinnedTimeline.swift b/Tusker/Models/PinnedTimeline.swift new file mode 100644 index 00000000..b9195fe6 --- /dev/null +++ b/Tusker/Models/PinnedTimeline.swift @@ -0,0 +1,129 @@ +// +// PinnedTimeline.swift +// Tusker +// +// Created by Shadowfacts on 1/27/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +enum PinnedTimeline: Codable, Equatable, Hashable { + case home + case `public`(local: Bool) + case tag(hashtag: String) + case list(id: String) + case instance(URL) + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "home": + self = .home + case "public": + self = .public(local: try container.decode(Bool.self, forKey: .local)) + case "tag": + self = .tag(hashtag: try container.decode(String.self, forKey: .hashtag)) + case "list": + self = .list(id: try container.decode(String.self, forKey: .listID)) + case "instance": + self = .instance(try container.decode(URL.self, forKey: .instanceURL)) + default: + throw DecodingError.dataCorruptedError(forKey: CodingKeys.type, in: container, debugDescription: "PinnedTimeline type must be one of 'home', 'local', 'tag', 'list', or 'instance'") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .home: + try container.encode("home", forKey: .type) + case .public(let local): + try container.encode("public", forKey: .type) + try container.encode(local, forKey: .local) + case .tag(let hashtag): + try container.encode("tag", forKey: .type) + try container.encode(hashtag, forKey: .hashtag) + case .list(let id): + try container.encode("list", forKey: .type) + try container.encode(id, forKey: .listID) + case .instance(let url): + try container.encode("instance", forKey: .type) + try container.encode(url, forKey: .instanceURL) + } + } + + init?(timeline: Timeline) { + switch timeline { + case .home: + self = .home + case .public(let local): + self = .public(local: local) + case .tag(let hashtag): + self = .tag(hashtag: hashtag) + case .list(let id): + self = .list(id: id) + case .direct: + return nil + } + } + + var timeline: Timeline? { + switch self { + case .home: + return .home + case .public(let local): + return .public(local: local) + case .tag(let hashtag): + return .tag(hashtag: hashtag) + case .list(let id): + return .list(id: id) + case .instance(_): + return nil + } + } + + var title: String { + switch self { + case .home: + return "Home" + case let .public(local): + return local ? "Local" : "Federated" + case let .tag(hashtag): + return "#\(hashtag)" + case .list: + return "List" + case .instance(let url): + return url.host! + } + } + + var image: UIImage { + switch self { + case .home: + return UIImage(systemName: "house.fill")! + case let .public(local): + if local { + return UIImage(systemName: "person.and.person.fill")! + } else { + return UIImage(systemName: "globe")! + } + case .list(id: _): + return UIImage(systemName: "list.bullet")! + case .tag(hashtag: _): + return UIImage(systemName: "number")! + case .instance(_): + return UIImage(systemName: "globe")! + } + } + + private enum CodingKeys: String, CodingKey { + case type + case local + case hashtag + case listID + case instanceURL + } +} diff --git a/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift b/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift index 57a28421..f4604c4a 100644 --- a/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift +++ b/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift @@ -13,7 +13,7 @@ struct AddHashtagPinnedTimelineView: View { @EnvironmentObject private var mastodonController: MastodonController @Environment(\.dismiss) private var dismiss - @Binding var pinnedTimelines: [Timeline] + @Binding var pinnedTimelines: [PinnedTimeline] @StateObject private var viewModel = SearchViewModel() @State private var searchTask: Task? @State private var isSearching = false @@ -34,7 +34,7 @@ struct AddHashtagPinnedTimelineView: View { var body: some View { NavigationView { list - .navigationTitle("Search") + .navigationTitle("Add Hashtag") .navigationBarTitleDisplayMode(.inline) .searchable(text: $viewModel.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Search for hashtags")) .toolbar { diff --git a/Tusker/Screens/Customize Timelines/AddInstancePinnedTimelineView.swift b/Tusker/Screens/Customize Timelines/AddInstancePinnedTimelineView.swift new file mode 100644 index 00000000..54bba82e --- /dev/null +++ b/Tusker/Screens/Customize Timelines/AddInstancePinnedTimelineView.swift @@ -0,0 +1,47 @@ +// +// AddInstancePinnedTimelineView.swift +// Tusker +// +// Created by Shadowfacts on 1/27/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm + +struct AddInstancePinnedTimelineView: UIViewControllerRepresentable { + typealias UIViewControllerType = UINavigationController + + @Binding var pinnedTimelines: [PinnedTimeline] + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UINavigationController { + let vc = InstanceSelectorTableViewController() + vc.title = "Add Instance" + vc.delegate = context.coordinator + vc.navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .cancel, primaryAction: UIAction(handler: { _ in + dismiss() + })) + return UINavigationController(rootViewController: vc) + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { + } + + func makeCoordinator() -> Coordinator { + let coordinator = Coordinator() + coordinator.didSelect = { + pinnedTimelines.append(.instance($0)) + dismiss() + } + return coordinator + } + + class Coordinator: InstanceSelectorTableViewControllerDelegate { + var didSelect: ((URL) -> Void)? + + func didSelectInstance(url: URL) { + didSelect?(url) + } + } +} diff --git a/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift b/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift index 9ee5c0c8..504731db 100644 --- a/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift +++ b/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift @@ -14,8 +14,9 @@ struct PinnedTimelinesView: View { @ObservedObject private var accountPreferences: AccountPreferences @State private var isShowingAddHashtagSheet = false + @State private var isShowingAddInstanceSheet = false // store this separately from AccountPreferences in the view, b/c the @LazilyDecoding wrapper breaks animations - @State private var pinnedTimelines: [Timeline] + @State private var pinnedTimelines: [PinnedTimeline] init(accountPreferences: AccountPreferences) { self.accountPreferences = accountPreferences @@ -61,7 +62,7 @@ struct PinnedTimelinesView: View { }) Menu { - ForEach([Timeline.home, .public(local: true), .public(local: false)], id: \.id) { timeline in + ForEach([PinnedTimeline.home, .public(local: true), .public(local: false)], id: \.id) { timeline in Button { withAnimation { pinnedTimelines.append(timeline) @@ -80,12 +81,12 @@ struct PinnedTimelinesView: View { ForEach(mastodonController.lists, id: \.id) { list in Button { withAnimation { - pinnedTimelines.append(list.timeline) + pinnedTimelines.append(.list(id: list.id)) } } label: { Text(list.title) } - .disabled(pinnedTimelines.contains(list.timeline)) + .disabled(pinnedTimelines.contains(.list(id: list.id))) } } @@ -94,6 +95,12 @@ struct PinnedTimelinesView: View { } label: { Label("Hashtag…", systemImage: "number") } + + Button { + isShowingAddInstanceSheet = true + } label: { + Label("Instance…", systemImage: "globe") + } } label: { Label("Add…", systemImage: "plus") .padding(.horizontal, 20) @@ -106,6 +113,10 @@ struct PinnedTimelinesView: View { .sheet(isPresented: $isShowingAddHashtagSheet, content: { AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines) }) + .sheet(isPresented: $isShowingAddInstanceSheet, content: { + AddInstancePinnedTimelineView(pinnedTimelines: $pinnedTimelines) + .edgesIgnoringSafeArea(.bottom) + }) .onReceive(accountPreferences.publisher(for: \.pinnedTimelinesData)) { _ in if pinnedTimelines != accountPreferences.pinnedTimelines { pinnedTimelines = accountPreferences.pinnedTimelines @@ -119,7 +130,7 @@ struct PinnedTimelinesView: View { } } -fileprivate extension Timeline { +fileprivate extension PinnedTimeline { var id: String { switch self { case .home: @@ -130,8 +141,8 @@ fileprivate extension Timeline { return "list:\(id)" case .tag(hashtag: let tag): return "tag:\(tag)" - case .direct: - return "direct" + case .instance(let url): + return "instance:\(url.host!)" } } } diff --git a/Tusker/Screens/Timeline/TimelinesPageViewController.swift b/Tusker/Screens/Timeline/TimelinesPageViewController.swift index 0143ee73..0c3d06f0 100644 --- a/Tusker/Screens/Timeline/TimelinesPageViewController.swift +++ b/Tusker/Screens/Timeline/TimelinesPageViewController.swift @@ -28,7 +28,12 @@ class TimelinesPageViewController: SegmentedPageViewController Bool { return lhs.timeline == rhs.timeline diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index 5c854326..f8dd75a3 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -216,10 +216,11 @@ class UserActivityManager { return } - if mastodonController.accountPreferences.pinnedTimelines.contains(timeline) { + if let pinned = PinnedTimeline(timeline: timeline), + mastodonController.accountPreferences.pinnedTimelines.contains(pinned) { navigationController.popToRootViewController(animated: false) let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController - rootController.selectTimeline(timeline, animated: false) + rootController.selectTimeline(pinned, animated: false) } else { let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController) navigationController.pushViewController(timeline, animated: false) diff --git a/TuskerTests/PinnedTimelineTests.swift b/TuskerTests/PinnedTimelineTests.swift new file mode 100644 index 00000000..d3e5d003 --- /dev/null +++ b/TuskerTests/PinnedTimelineTests.swift @@ -0,0 +1,27 @@ +// +// PinnedTimelineTests.swift +// TuskerTests +// +// Created by Shadowfacts on 1/27/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import XCTest +@testable import Tusker +import Pachyderm + +final class PinnedTimelineTests: XCTestCase { + + func testDecodeFromTimeline() throws { + let timeline = Timeline.public(local: false) + let data = try JSONEncoder().encode(timeline) + let decoded = try JSONDecoder().decode(PinnedTimeline.self, from: data) + switch decoded { + case .public(local: false): + break + default: + XCTFail() + } + } + +}