Add pinned timeline customization

This commit is contained in:
Shadowfacts 2022-12-20 23:37:12 -05:00
parent 795146cde4
commit 4dc108f782
16 changed files with 518 additions and 139 deletions

View File

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

View File

@ -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 = "<group>"; };
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = "<group>"; };
D61F759829384D4D00C0B37F /* FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = "<group>"; };
D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeTimelinesView.swift; sourceTree = "<group>"; };
D61F759A29384F9C00C0B37F /* FilterMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMO.swift; sourceTree = "<group>"; };
D61F759C2938574B00C0B37F /* FilterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterRow.swift; sourceTree = "<group>"; };
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparator.swift; sourceTree = "<group>"; };
@ -560,6 +564,10 @@
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
D6895DE828D962C2006341DA /* TimelineLikeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeController.swift; sourceTree = "<group>"; };
D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPreferences.swift; sourceTree = "<group>"; };
D68A76E229524D2A001DA1B3 /* ListMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListMO.swift; sourceTree = "<group>"; };
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelinesView.swift; sourceTree = "<group>"; };
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddHashtagPinnedTimelineView.swift; sourceTree = "<group>"; };
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
@ -798,14 +806,16 @@
path = "Instance Cell";
sourceTree = "<group>";
};
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 = "<group>";
};
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 */,

View File

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

View File

@ -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<AccountPreferences> {
let req = NSFetchRequest<AccountPreferences>(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
}
}

View File

@ -28,6 +28,10 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="AccountPreferences" representedClassName="AccountPreferences" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
</entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
<attribute name="action" attributeType="String" defaultValueString="warn"/>
<attribute name="context" attributeType="String"/>
@ -121,6 +125,7 @@
<configuration name="Cloud" usedWithCloudKit="YES">
<memberEntity name="SavedHashtag"/>
<memberEntity name="SavedInstance"/>
<memberEntity name="AccountPreferences"/>
</configuration>
<configuration name="Local">
<memberEntity name="Account"/>

View File

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

View File

@ -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<Void, Never>?
@State private var isSearching = false
@State private var searchResults: [String] = []
private var savedAndFollowedHashtags: [String] {
var tags = Set<String>()
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()
// }
//}

View File

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

View File

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

View File

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

View File

@ -11,9 +11,6 @@ import Pachyderm
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> {
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<Notifications
self.initialMode = initialMode
self.mastodonController = mastodonController
let notifications = NotificationsTableViewController(allowedTypes: Pachyderm.Notification.Kind.allCases, mastodonController: mastodonController)
notifications.title = notificationsTitle
notifications.userActivity = UserActivityManager.checkNotificationsActivity(mode: .allNotifications)
super.init(pages: [.all, .mentions]) { page in
let vc = NotificationsTableViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
vc.title = page.title
vc.userActivity = page.userActivity
return vc
}
let mentions = NotificationsTableViewController(allowedTypes: [.mention], mastodonController: mastodonController)
mentions.title = mentionsTitle
mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
super.init(pages: [
(.all, notificationsTitle, notifications),
(.mentions, mentionsTitle, mentions),
])
title = notificationsTitle
title = Page.all.title
tabBarItem.image = UIImage(systemName: "bell.fill")
}
@ -61,9 +52,40 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
selectPage(page, animated: false)
}
enum Page {
enum Page: SegmentedPageViewControllerPage {
case all
case mentions
var title: String {
switch self {
case .all:
return NSLocalizedString("Notifications", comment: "notifications tab title")
case .mentions:
return NSLocalizedString("Mentions", comment: "mentions tab title")
}
}
var segmentedControlTitle: String {
title
}
var allowedTypes: [Pachyderm.Notification.Kind] {
switch self {
case .all:
return Pachyderm.Notification.Kind.allCases
case .mentions:
return [.mention]
}
}
var userActivity: NSUserActivity {
switch self {
case .all:
return UserActivityManager.checkNotificationsActivity(mode: .allNotifications)
case .mentions:
return UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
}
}
}
}

View File

@ -8,6 +8,7 @@
import UIKit
import SwiftUI
import Pachyderm
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
@ -17,33 +18,27 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
weak var mastodonController: MastodonController!
private var pinnedTimelinesObservation: NSKeyValueObservation?
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
home.title = homeTitle
home.persistsState = true
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
federated.title = federatedTitle
federated.persistsState = true
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
local.title = localTitle
local.persistsState = true
super.init(pages: [
(.home, "Home", home),
(.local, "Local", local),
(.federated, "Federated", federated),
])
let pages = mastodonController.accountPreferences.pinnedTimelines.map {
Page(mastodonController: mastodonController, timeline: $0)
}
super.init(pages: pages) { page in
let vc = TimelineViewController(for: page.timeline, mastodonController: page.mastodonController)
vc.title = page.segmentedControlTitle
vc.persistsState = true
return vc
}
title = homeTitle
tabBarItem.image = UIImage(systemName: "house.fill")
let filtersItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), style: .plain, target: self, action: #selector(filtersPressed))
filtersItem.accessibilityLabel = "Filters"
navigationItem.leftBarButtonItem = filtersItem
let customizeItem = UIBarButtonItem(image: UIImage(systemName: "slider.horizontal.3"), style: .plain, target: self, action: #selector(customizePressed))
customizeItem.accessibilityLabel = "Customize Timelines"
navigationItem.rightBarButtonItem = customizeItem
let jumpToPresentName = NSMutableAttributedString("Jump to Present")
// otherwise it pronounces it as 'pɹizˈənt'
@ -51,7 +46,7 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
jumpToPresentName.addAttribute(.accessibilitySpeechIPANotation, value: "ˈprɛ.zənt", range: NSRange(location: "Jump to ".count, length: "Present".count))
segmentedControl.accessibilityCustomActions = [
UIAccessibilityCustomAction(attributedName: jumpToPresentName, actionHandler: { [unowned self] _ in
guard let vc = pageControllers[currentIndex] as? TimelineViewController else {
guard let vc = currentViewController as? TimelineViewController else {
return false
}
Task {
@ -60,42 +55,61 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
return true
})
]
pinnedTimelinesObservation = mastodonController.accountPreferences.observe(\.pinnedTimelinesData, changeHandler: { [unowned self] _, _ in
let pages = self.mastodonController.accountPreferences.pinnedTimelines.map {
Page(mastodonController: self.mastodonController, timeline: $0)
}
self.setPages(pages, animated: false)
})
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func selectTimeline(_ timeline: Timeline, animated: Bool) {
self.selectPage(Page(mastodonController: mastodonController, timeline: timeline), animated: animated)
}
func stateRestorationActivity() -> 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
}
}
}
}

View File

@ -8,35 +8,39 @@
import UIKit
class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController {
protocol SegmentedPageViewControllerPage: Hashable {
var segmentedControlTitle: String { get }
}
let pages: [Page]
let pageControllers: [UIViewController]
class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: 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<Page>()
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<Page: Hashable>: 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<Page: Hashable>: 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<Page: Hashable>: 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

View File

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

View File

@ -89,6 +89,9 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)"
optionsStack.addArrangedSubview(label)
}
updateSelectedIndicatorView()
invalidateIntrinsicContentSize()
}
func setSelectedOption(_ value: Value, animated: Bool) {