Compare commits

..

No commits in common. "13d649bacec6258c44416d04bf2d03d234b56f02" and "e49725e06dd52c9dac421fd7a1c56717f62485ad" have entirely different histories.

19 changed files with 52 additions and 312 deletions

View File

@ -1,27 +1,5 @@
# Changelog # Changelog
## 2022.1 (51)
Features/Improvements:
- Clarify text for conversation main status favorite/reblog count preference
- Improve timeline refresh behavior
- Present posts are inserted automatically, creating a gap
- Tapping the Jump to Present bubble scrolls to the top and removes everything below the gap
- Add preference to disable timeline state restoration (Preferences -> Behavior)
- VoiceOver: Add Jump to Present action on the timeline selector segmented control
- VoiceOver: Add accessibility hint for segmented controls when using the group navigation mode
- VoiceOver: Improve description of timeline gap and add actions to load in a specific direction
Bugfixes:
- Fix crash due to change cache location on disk
- Fix potential crash when trying to restore statuses that aren't available
- Fix saving expired filters not re-enabling them
- Fix tusker:// URL scheme not working
- Fix Trending Posts reloading constantly
- Fix crash when timeline tries to refresh while in the background
## 2022.1 (50)
This is a hotfix for Dynamic Type not working in the timeline. The previous build's changelog is included below.
## 2022.1 (49) ## 2022.1 (49)
The major new feature of this build is filters! Filters are editable by pressing the Filters button in the top-left corner of the Home tab. Filters are currently applied to timelines and profiles (filtering conversations and notifications will be added in a future build). The major new feature of this build is filters! Filters are editable by pressing the Filters button in the top-left corner of the Home tab. Filters are currently applied to timelines and profiles (filtering conversations and notifications will be added in a future build).

View File

@ -2291,7 +2291,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51; CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2359,7 +2359,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51; CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2509,7 +2509,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51; CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2538,7 +2538,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51; CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2648,7 +2648,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51; CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2675,7 +2675,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51; CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;

View File

@ -8,7 +8,6 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
import Sentry
struct InstanceFeatures { struct InstanceFeatures {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive) private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
@ -137,8 +136,6 @@ struct InstanceFeatures {
} }
maxStatusChars = instance.maxStatusCharacters ?? 500 maxStatusChars = instance.maxStatusCharacters ?? 500
setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
} }
func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
@ -247,19 +244,3 @@ extension InstanceFeatures {
} }
} }
} }
private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
let crumb = Breadcrumb(level: .info, category: "MastodonController")
crumb.data = [
"instance": [
"version": instance.version
],
]
if let nodeInfo {
crumb.data!["nodeInfo"] = [
"version": nodeInfo.version,
"software": nodeInfo.software,
]
}
SentrySDK.addBreadcrumb(crumb: crumb)
}

View File

@ -54,34 +54,6 @@ class MastodonCachePersistentStore: NSPersistentContainer {
persistentStoreDescriptions = [storeDescription] persistentStoreDescriptions = [storeDescription]
} else { } else {
super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel) super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
// workaround for migrating from using id in name to persistenceKey
// can be removed after a sufficient time has passed
if accountInfo!.id.contains("/") {
for desc in persistentStoreDescriptions {
guard let new = desc.url,
!FileManager.default.fileExists(atPath: new.path) else {
continue
}
do {
for ext in ["sqlite", "sqlite-shm", "sqlite-wal"] {
var old = new.deletingLastPathComponent()
let components = accountInfo!.id.split(separator: "/")
for dir in components.dropLast(1) {
old.appendPathComponent(String(dir), isDirectory: true)
}
old.appendPathComponent("\(components.last!)_cache", isDirectory: false)
old.appendPathExtension(ext)
if FileManager.default.fileExists(atPath: old.path) {
var expected = new.deletingLastPathComponent()
expected.appendPathComponent("\(accountInfo!.persistenceKey)_cache", isDirectory: false)
expected.appendPathExtension(ext)
try FileManager.default.moveItem(at: old, to: expected)
}
}
} catch {}
}
}
} }
loadPersistentStores { (description, error) in loadPersistentStores { (description, error) in

View File

@ -47,19 +47,6 @@
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>net.shadowfacts.Tusker</string>
<key>CFBundleURLSchemes</key>
<array>
<string>tusker</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>

View File

@ -10,8 +10,6 @@ import Foundation
import Pachyderm import Pachyderm
class EditedFilter: ObservableObject { class EditedFilter: ObservableObject {
static let defaultExpiresInForExpired: TimeInterval = 24 * 60 * 60
let id: String? let id: String?
@Published var title: String? @Published var title: String?
@Published var contexts: [FilterV1.Context] @Published var contexts: [FilterV1.Context]
@ -33,11 +31,7 @@ class EditedFilter: ObservableObject {
self.title = mo.title self.title = mo.title
self.contexts = mo.contexts self.contexts = mo.contexts
if let expiresAt = mo.expiresAt { if let expiresAt = mo.expiresAt {
if expiresAt <= Date() { expiresIn = expiresAt.timeIntervalSinceNow
expiresIn = EditedFilter.defaultExpiresInForExpired
} else {
expiresIn = expiresAt.timeIntervalSinceNow
}
} }
self.keywords = mo.keywordMOs.map { self.keywords = mo.keywordMOs.map {
Keyword(id: $0.id, keyword: $0.keyword, wholeWord: $0.wholeWord) Keyword(id: $0.id, keyword: $0.keyword, wholeWord: $0.wholeWord)

View File

@ -63,7 +63,6 @@ class Preferences: Codable, ObservableObject {
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? [] self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts) self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType) self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
@ -106,7 +105,6 @@ class Preferences: Codable, ObservableObject {
try container.encode(collapseLongPosts, forKey: .collapseLongPosts) try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords) try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog) try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts) try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType) try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
@ -158,7 +156,6 @@ class Preferences: Codable, ObservableObject {
@Published var collapseLongPosts = true @Published var collapseLongPosts = true
@Published var oppositeCollapseKeywords: [String] = [] @Published var oppositeCollapseKeywords: [String] = []
@Published var confirmBeforeReblog = false @Published var confirmBeforeReblog = false
@Published var timelineStateRestoration = true
// MARK: Digital Wellness // MARK: Digital Wellness
@Published var showFavoriteAndReblogCounts = true @Published var showFavoriteAndReblogCounts = true
@ -202,7 +199,6 @@ class Preferences: Codable, ObservableObject {
case collapseLongPosts case collapseLongPosts
case oppositeCollapseKeywords case oppositeCollapseKeywords
case confirmBeforeReblog case confirmBeforeReblog
case timelineStateRestoration
case showFavoriteAndReblogCounts case showFavoriteAndReblogCounts
case defaultNotificationsType case defaultNotificationsType

View File

@ -18,8 +18,6 @@ class TrendingStatusesViewController: UIViewController {
} }
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var loaded = false
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -91,10 +89,7 @@ class TrendingStatusesViewController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false)
Task { Task {
if !loaded { await loadTrendingStatuses()
loaded = true
await loadTrendingStatuses()
}
} }
} }

View File

@ -27,21 +27,18 @@ struct EditFilterView: View {
return durations.map { .init(value: $0, title: f.string(from: $0)!) } return durations.map { .init(value: $0, title: f.string(from: $0)!) }
}() }()
@ObservedObject private var filter: EditedFilter @ObservedObject var filter: EditedFilter
private let create: Bool let create: Bool
private let originallyExpired: Bool
@EnvironmentObject private var mastodonController: MastodonController @EnvironmentObject private var mastodonController: MastodonController
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var expiresIn: TimeInterval
@State private var originalFilter: EditedFilter @State private var originalFilter: EditedFilter
@State private var edited = false @State private var edited = false
@State private var isSaving = false @State private var isSaving = false
@State private var saveError: (any Error)? @State private var saveError: (any Error)?
init(filter: EditedFilter, create: Bool, originallyExpired: Bool) { init(filter: EditedFilter, create: Bool) {
self.filter = filter self.filter = filter
self.create = create self.create = create
self.originallyExpired = originallyExpired
self._originalFilter = State(wrappedValue: EditedFilter(copying: filter)) self._originalFilter = State(wrappedValue: EditedFilter(copying: filter))
if let expiresIn = filter.expiresIn { if let expiresIn = filter.expiresIn {
self._expiresIn = State(wrappedValue: Self.expiresInOptions.min(by: { a, b in self._expiresIn = State(wrappedValue: Self.expiresInOptions.min(by: { a, b in
@ -50,10 +47,17 @@ struct EditFilterView: View {
return aDist < bDist return aDist < bDist
})!.value) })!.value)
} else { } else {
self._expiresIn = State(wrappedValue: EditedFilter.defaultExpiresInForExpired) self._expiresIn = State(wrappedValue: 24 * 60 * 60)
} }
} }
@State private var expiresIn: TimeInterval {
didSet {
if expires.wrappedValue {
filter.expiresIn = expiresIn
}
}
}
private var expires: Binding<Bool> { private var expires: Binding<Bool> {
Binding { Binding {
filter.expiresIn != nil filter.expiresIn != nil
@ -154,7 +158,7 @@ struct EditFilterView: View {
Button(create ? "Create" : "Save") { Button(create ? "Create" : "Save") {
saveFilter() saveFilter()
} }
.disabled(!filter.isValid(for: mastodonController) || (!edited && originallyExpired)) .disabled(!filter.isValid(for: mastodonController) || !edited)
} }
} }
} }
@ -163,12 +167,6 @@ struct EditFilterView: View {
}, message: { error in }, message: { error in
Text(error.localizedDescription) Text(error.localizedDescription)
}) })
.onChange(of: expiresIn, perform: { newValue in
edited = true
if expires.wrappedValue {
filter.expiresIn = newValue
}
})
.onReceive(filter.objectWillChange, perform: { _ in .onReceive(filter.objectWillChange, perform: { _ in
edited = true edited = true
}) })

View File

@ -51,7 +51,7 @@ struct FiltersList: View {
List { List {
Section { Section {
NavigationLink { NavigationLink {
EditFilterView(filter: EditedFilter(), create: true, originallyExpired: false) EditFilterView(filter: EditedFilter(), create: true)
} label: { } label: {
Label("Add Filter", systemImage: "plus") Label("Add Filter", systemImage: "plus")
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
@ -88,7 +88,7 @@ struct FiltersList: View {
Section { Section {
ForEach(filters, id: \.id) { filter in ForEach(filters, id: \.id) { filter in
NavigationLink { NavigationLink {
EditFilterView(filter: EditedFilter(filter), create: false, originallyExpired: filter.expiresAt != nil && filter.expiresAt! <= Date()) EditFilterView(filter: EditedFilter(filter), create: false)
} label: { } label: {
FilterRow(filter: filter) FilterRow(filter: filter)
} }

View File

@ -14,7 +14,6 @@ struct BehaviorPrefsView: View {
var body: some View { var body: some View {
List { List {
untitledSection untitledSection
timelineSection
linksSection linksSection
contentWarningsSection contentWarningsSection
} }
@ -29,16 +28,6 @@ struct BehaviorPrefsView: View {
} }
} }
} }
private var timelineSection: some View {
Section {
Toggle(isOn: $preferences.timelineStateRestoration) {
Text("Maintain Position Across App Launches")
}
} header: {
Text("Timeline")
}
}
private var linksSection: some View { private var linksSection: some View {
Section(header: Text("Links")) { Section(header: Text("Links")) {

View File

@ -24,9 +24,9 @@ struct WellnessPrefsView: View {
} }
private var showFavAndReblogCount: some View { private var showFavAndReblogCount: some View {
Section(footer: Text("Control whether the focused post in the conversation view shows total favorite and reblog counts.")) { Section(footer: Text("Control whether total favorite and reblog counts are shown for the main post in conversations.")) {
Toggle(isOn: $preferences.showFavoriteAndReblogCounts) { Toggle(isOn: $preferences.showFavoriteAndReblogCounts) {
Text("Favorite and Reblog Counts in Conversations") Text("Show Favorite and Reblog Counts")
} }
} }
} }

View File

@ -15,8 +15,6 @@ class TimelineGapCollectionViewCell: UICollectionViewCell {
private let indicator = UIActivityIndicatorView(style: .medium) private let indicator = UIActivityIndicatorView(style: .medium)
private let chevronView = AnimatingChevronView() private let chevronView = AnimatingChevronView()
var fillGap: ((TimelineGapDirection) async -> Void)?
override var isHighlighted: Bool { override var isHighlighted: Bool {
didSet { didSet {
backgroundColor = isHighlighted ? .systemFill : .systemGroupedBackground backgroundColor = isHighlighted ? .systemFill : .systemGroupedBackground
@ -102,36 +100,6 @@ class TimelineGapCollectionViewCell: UICollectionViewCell {
} }
} }
// MARK: Accessibility
override var isAccessibilityElement: Bool {
get { true }
set {}
}
override var accessibilityLabel: String? {
get {
"Load \(direction.accessibilityLabel)"
}
set {}
}
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get {
[TimelineGapDirection.below, .above].map { dir in
UIAccessibilityCustomAction(name: "Load \(dir.accessibilityLabel)") { [unowned self] _ in
Task {
showsIndicator = true
await fillGap?(dir)
showsIndicator = false
}
return true
}
}
}
set {}
}
} }
private class AnimatingChevronView: UIView { private class AnimatingChevronView: UIView {

View File

@ -25,8 +25,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private var contentOffsetObservation: NSKeyValueObservation? private var contentOffsetObservation: NSKeyValueObservation?
private var activityToRestore: NSUserActivity? private var activityToRestore: NSUserActivity?
// the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing
private var disappearedAt: Date?
init(for timeline: Timeline, mastodonController: MastodonController!) { init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline self.timeline = timeline
@ -117,7 +115,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sceneDidEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil)
} }
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
@ -137,11 +134,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
} }
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
cell.showsIndicator = false cell.showsIndicator = false
cell.fillGap = { [unowned self] direction in
await self.controller.fillGap(in: direction)
}
} }
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
guard case .public(let local) = timeline else { guard case .public(let local) = timeline else {
@ -159,7 +153,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
let (result, attributedString) = filterResult(state: filterState, statusID: id) let (result, attributedString) = filterResult(state: filterState, statusID: id)
switch result { switch result {
case .allow, .warn(_): case .allow, .warn(_):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result, attributedString)) return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result, nil))
case .hide: case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
} }
@ -198,15 +192,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
if doRestore() { if doRestore() {
Task { Task {
await checkPresent(jumpImmediately: false) await checkPresent()
} }
} else { } else {
Task { Task {
await controller.loadInitial() await controller.loadInitial()
} }
} }
} else {
checkPresentIfEnoughTimeElapsed()
} }
} }
@ -223,12 +215,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
disappearedAt = Date()
}
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
guard isViewLoaded else { guard isViewLoaded else {
return nil return nil
@ -298,8 +284,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
private func doRestore() -> Bool { private func doRestore() -> Bool {
guard let activity = activityToRestore, guard let activity = activityToRestore else {
Preferences.shared.timelineStateRestoration else {
return false return false
} }
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else { guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
@ -307,10 +292,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return false return false
} }
activityToRestore = nil activityToRestore = nil
let existingStatuses = statusIDs.filter { mastodonController.persistentContainer.status(for: $0) != nil }
guard !existingStatuses.isEmpty else {
return false
}
loadViewIfNeeded() loadViewIfNeeded()
controller.restoreInitial { controller.restoreInitial {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
@ -390,16 +371,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
view.window?.windowScene == scene else { view.window?.windowScene == scene else {
return return
} }
checkPresentIfEnoughTimeElapsed() Task {
} await checkPresent()
@objc private func sceneDidEnterBackground(_ notification: Foundation.Notification) {
guard let scene = notification.object as? UIScene,
// view.window is nil when the VC is not on screen
view.window?.windowScene == scene else {
return
} }
disappearedAt = Date()
} }
@objc func refresh() { @objc func refresh() {
@ -421,43 +395,21 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController // I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
let (_, presentItems) = await (loadNewerAndEndRefreshing(), try? loadInitial()) let (_, presentItems) = await (loadNewerAndEndRefreshing(), try? loadInitial())
if let presentItems, !presentItems.isEmpty { if let presentItems, !presentItems.isEmpty {
insertPresentItemsAndShowJumpToast(presentItems) insertPresentItemsIfNecessary(presentItems)
} }
} }
} }
} }
private func checkPresentIfEnoughTimeElapsed() { private func checkPresent() async {
guard let disappearedAt,
-disappearedAt.timeIntervalSinceNow > 60 * 60 /* 1 hour */ else {
return
}
self.disappearedAt = nil
Task {
await checkPresent(jumpImmediately: false)
}
}
func checkPresent(jumpImmediately: Bool) async {
if case .idle = controller.state, if case .idle = controller.state,
let presentItems = try? await loadInitial(), let presentItems = try? await loadInitial() {
!presentItems.isEmpty { insertPresentItemsIfNecessary(presentItems)
if jumpImmediately {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) {
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: false)
UIAccessibility.post(notification: .screenChanged, argument: self.collectionView.cellForItem(at: IndexPath(row: 0, section: 0)))
}
} else {
insertPresentItemsAndShowJumpToast(presentItems)
}
} }
} }
private func insertPresentItemsAndShowJumpToast(_ presentItems: [String]) { private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
var snapshot = dataSource.snapshot() let snapshot = dataSource.snapshot()
guard snapshot.indexOfSection(.statuses) != nil else { guard snapshot.indexOfSection(.statuses) != nil else {
return return
} }
@ -465,38 +417,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if case .status(id: let firstID, _, _) = currentItems.first, if case .status(id: let firstID, _, _) = currentItems.first,
// if there's no overlap between presentItems and the existing items in the data source, prompt the user // if there's no overlap between presentItems and the existing items in the data source, prompt the user
!presentItems.contains(firstID) { !presentItems.contains(firstID) {
let applySnapshotBeforeScrolling: Bool
// remove any existing gap, if there is one // create a new snapshot to reset the timeline to the "present" state
if let index = currentItems.lastIndex(of: .gap) { var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.deleteItems(Array(currentItems[index...])) snapshot.appendSections([.statuses])
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
let statusesSection = snapshot.indexOfSection(.statuses)!
if collectionView.indexPathsForVisibleItems.contains(IndexPath(row: index, section: statusesSection)) {
// the gap cell is on screen
applySnapshotBeforeScrolling = false
} else if let topMostVisibleCell = collectionView.indexPathsForVisibleItems.first(where: { $0.section == statusesSection }),
index < topMostVisibleCell.row {
// the gap cell is above the top, so applying the snapshot would remove the currently-viewed statuses
applySnapshotBeforeScrolling = false
} else {
// the gap cell is below the bottom of the screen
applySnapshotBeforeScrolling = true
}
} else {
// there is no existing gap
applySnapshotBeforeScrolling = true
}
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
snapshot.insertItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, beforeItem: .gap)
if applySnapshotBeforeScrolling { var config = ToastConfiguration(title: "Jump to present")
let firstVisibleIndexPath = collectionView.indexPathsForVisibleItems.min()!
let firstVisibleItem = dataSource.itemIdentifier(for: firstVisibleIndexPath)!
applySnapshot(snapshot, maintainingBottomRelativeScrollPositionOf: firstVisibleItem)
}
var config = ToastConfiguration(title: "Jump to Present")
config.edge = .top config.edge = .top
config.systemImageName = "arrow.up" config.systemImageName = "arrow.up"
config.dismissAutomaticallyAfter = 4 config.dismissAutomaticallyAfter = 4
@ -513,24 +440,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} else { } else {
origItemAtTop = nil origItemAtTop = nil
} }
// when the user explicitly taps Jump to Present, we drop all the old items to let infinite scrolling work properly when they scroll back down self.dataSource.apply(snapshot, animatingDifferences: true) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
// don't animate the snapshot change, the scrolling animation will paper over the switch
self.dataSource.apply(snapshot, animatingDifferences: false) {
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true) self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
var config = ToastConfiguration(title: "Go Back") var config = ToastConfiguration(title: "Go back")
config.edge = .top config.edge = .top
config.systemImageName = "arrow.down" config.systemImageName = "arrow.down"
config.dismissAutomaticallyAfter = 2 config.dismissAutomaticallyAfter = 4
config.action = { [unowned self] toast in config.action = { [unowned self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
// todo: it would be nice if we could animate this, but that doesn't work with the screen-position-maintaining stuff // todo: it would be nice if we could animate this, but that doesn't work with the screen-position-maintaining stuff
if let (item, offset) = origItemAtTop { if let (item, offset) = origItemAtTop {
self.applySnapshot(origSnapshot, maintainingScreenPosition: offset, ofItem: item) self.applySnapshot(snapshot, maintainingScreenPosition: offset, ofItem: item)
} else { } else {
self.dataSource.apply(origSnapshot, animatingDifferences: false) self.dataSource.apply(origSnapshot, animatingDifferences: false)
} }
@ -555,12 +477,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingScreenPosition offsetFromTop: CGFloat, ofItem itemToMaintain: Item) { private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingScreenPosition offsetFromTop: CGFloat, ofItem itemToMaintain: Item) {
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back // use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false) let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
if let snapshotView { snapshotView.layer.zPosition = 1000
snapshotView.layer.zPosition = 1000 snapshotView.frame = view.bounds
snapshotView.frame = view.bounds view.addSubview(snapshotView)
view.addSubview(snapshotView)
}
dataSource.apply(snapshot, animatingDifferences: false) { dataSource.apply(snapshot, animatingDifferences: false) {
if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) { if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) {
@ -569,17 +489,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area // firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
var cur = indexPathOfItemToMaintain var cur = indexPathOfItemToMaintain
var amountScrolledUp: CGFloat = 0 var amountScrolledUp: CGFloat = 0
var first = true
while true { while true {
if cur.row <= 0 { if cur.row <= 0 {
break break
} }
if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain), if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain),
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > offsetFromTop { cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > offsetFromTop {
// if we're breaking from the loop at the first iteration, we need to make sure to still call scrollToItem for the current row
if first {
self.collectionView.scrollToItem(at: cur, at: .top, animated: false)
}
break break
} }
cur = IndexPath(row: cur.row - 1, section: cur.section) cur = IndexPath(row: cur.row - 1, section: cur.section)
@ -587,13 +502,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
self.collectionView.layoutIfNeeded() self.collectionView.layoutIfNeeded()
let attrs = self.collectionView.layoutAttributesForItem(at: cur)! let attrs = self.collectionView.layoutAttributesForItem(at: cur)!
amountScrolledUp += attrs.size.height amountScrolledUp += attrs.size.height
first = false
} }
self.collectionView.contentOffset.y += amountScrolledUp self.collectionView.contentOffset.y += amountScrolledUp
self.collectionView.contentOffset.y -= offsetFromTop self.collectionView.contentOffset.y -= offsetFromTop
} }
snapshotView?.removeFromSuperview() snapshotView.removeFromSuperview()
} }
} }

View File

@ -45,22 +45,6 @@ class TimelinesPageViewController: SegmentedPageViewController {
let filtersItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), style: .plain, target: self, action: #selector(filtersPressed)) let filtersItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), style: .plain, target: self, action: #selector(filtersPressed))
filtersItem.accessibilityLabel = "Filters" filtersItem.accessibilityLabel = "Filters"
navigationItem.leftBarButtonItem = filtersItem navigationItem.leftBarButtonItem = filtersItem
let jumpToPresentName = NSMutableAttributedString("Jump to Present")
// otherwise it pronounces it as 'pɹizˈənt'
// its IPA is also bad, this should be an alveolar approximant not a trill
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 {
return false
}
Task {
await vc.checkPresent(jumpImmediately: true)
}
return true
})
]
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {

View File

@ -32,9 +32,6 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
// before the view has necessarily loaded // before the view has necessarily loaded
segmentedControl = UISegmentedControl(items: titles) segmentedControl = UISegmentedControl(items: titles)
segmentedControl.addTarget(self, action: #selector(segmentedControlChanged), for: .valueChanged) segmentedControl.addTarget(self, action: #selector(segmentedControlChanged), for: .valueChanged)
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode,
// so make it clear that to switch tabs the user needs to enter the group
segmentedControl.accessibilityHint = "Enter group to select timeline"
navigationItem.titleView = segmentedControl navigationItem.titleView = segmentedControl
} }

View File

@ -379,13 +379,4 @@ enum TimelineGapDirection {
case below case below
/// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap. /// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap.
case above case above
var accessibilityLabel: String {
switch self {
case .below:
return "Newer"
case .above:
return "Older"
}
}
} }

View File

@ -83,10 +83,6 @@ class ProfileHeaderView: UIView {
noteTextView.defaultFont = .preferredFont(forTextStyle: .body) noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
noteTextView.adjustsFontForContentSizeCategory = true noteTextView.adjustsFontForContentSizeCategory = true
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode,
// so make it clear that to switch tabs the user needs to enter the group
pagesSegmentedControl.accessibilityHint = "Enter group to select scope"
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
} }

View File

@ -166,7 +166,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
} }
let contentContainer = StatusContentContainer().configure { let contentContainer = StatusContentContainer().configure {
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont $0.contentTextView.font = TimelineStatusCollectionViewCell.contentFont
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle $0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
$0.setContentHuggingPriority(.defaultLow, for: .vertical) $0.setContentHuggingPriority(.defaultLow, for: .vertical)
} }