Compare commits

..

20 Commits

Author SHA1 Message Date
Shadowfacts 13d649bace Bump build number and update changelog 2022-12-05 22:24:10 -05:00
Shadowfacts bebe563e8f Further tweak persistent store migration 2022-12-05 19:32:59 -05:00
Shadowfacts 4be2258882 Fix saving expired filters not reenabling them
Closes #289
2022-12-05 19:01:32 -05:00
Shadowfacts 40ff8d0a2a VoiceOver: improve description of gap cell, add actions to specify direction 2022-12-05 18:43:32 -05:00
Shadowfacts 0dcb7e71c4 Also perform jump to present check when the timeline VC reappears onscreen 2022-12-05 18:27:23 -05:00
Shadowfacts 08878f2fb9 Re-add tusker:// scheme
Apparently it was accidentally removed in d661870401

Closes #287
2022-12-05 17:28:28 -05:00
Shadowfacts 3ea7e1057b Add preference to disable timeline state restoration 2022-12-05 17:24:01 -05:00
Shadowfacts fc8fcb76fd Fix crash when TimelineViewController tries to apply snapshot while not visible 2022-12-05 17:17:34 -05:00
Shadowfacts eac2a9b19f Move VoiceOver Jump to Present action to timeline pages segmented control 2022-12-05 17:13:45 -05:00
Shadowfacts 0ce57d1308 More fiddling with how Jump to Present works
Now, when loading present items, they're inserted into the data source
immediately along with a gap. If the user taps Jump to Present, then a
new snapshot _with only the present items_ will be applied (which allows
infinite scrolling to work properly when they scroll back down) and the
view scrolled-to-top. Tapping Go Back, then, applies the original
snapshot (i.e., the current one from when Jump to Present was tapped)
and restores the scroll position.
2022-12-05 17:09:11 -05:00
Shadowfacts 97dec0f9d2 Add accessibility hint for segmented controls 2022-12-05 16:25:16 -05:00
Shadowfacts b64c748b73 Add Jump to Present VoiceOver action
Closes #288
2022-12-04 22:06:04 -05:00
Shadowfacts 77ab2c3753 Fix Trending Posts reloading on every appearance 2022-12-04 22:03:48 -05:00
Shadowfacts b90262bfd0 Tweak fav/reblog counts pref text 2022-12-04 19:50:15 -05:00
Shadowfacts 581f4b24bd Add Sentry breadcrumb for instance software/version 2022-12-04 18:26:06 -05:00
Shadowfacts 5f3d9da9f8 Only try to restore statuses that exist in the cache
This could result in discontinuities in the restored timeline, but I'm
not sure there's anything better we could do.
2022-12-04 17:34:28 -05:00
Shadowfacts 41775e5d19 Actually migrate to new persistent store locations 2022-12-04 17:33:09 -05:00
Shadowfacts 044d34d20f Bump build number and update changelog 2022-12-04 15:40:00 -05:00
Shadowfacts f1b1732e5c Fix filter HTML to attributed string conversion optimization not being applied 🤦‍♂️ 2022-12-04 15:36:26 -05:00
Shadowfacts 1da2b17a76 Fix dynamic type not applying to timeline status content 2022-12-04 15:35:54 -05:00
19 changed files with 313 additions and 53 deletions

View File

@ -1,5 +1,27 @@
# 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 = 49; CURRENT_PROJECT_VERSION = 51;
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 = 49; CURRENT_PROJECT_VERSION = 51;
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 = 49; CURRENT_PROJECT_VERSION = 51;
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 = 49; CURRENT_PROJECT_VERSION = 51;
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 = 49; CURRENT_PROJECT_VERSION = 51;
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 = 49; CURRENT_PROJECT_VERSION = 51;
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,6 +8,7 @@
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)
@ -136,6 +137,8 @@ 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 {
@ -244,3 +247,19 @@ 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,6 +54,34 @@ 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,6 +47,19 @@
<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,6 +10,8 @@ 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]
@ -31,7 +33,11 @@ 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 {
expiresIn = expiresAt.timeIntervalSinceNow if expiresAt <= Date() {
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,6 +63,7 @@ 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)
@ -105,6 +106,7 @@ 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)
@ -156,6 +158,7 @@ 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
@ -199,6 +202,7 @@ 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,6 +18,8 @@ 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
@ -89,7 +91,10 @@ class TrendingStatusesViewController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false)
Task { Task {
await loadTrendingStatuses() if !loaded {
loaded = true
await loadTrendingStatuses()
}
} }
} }

View File

@ -27,18 +27,21 @@ 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 var filter: EditedFilter @ObservedObject private var filter: EditedFilter
let create: Bool private 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) { init(filter: EditedFilter, create: Bool, originallyExpired: 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
@ -47,17 +50,10 @@ struct EditFilterView: View {
return aDist < bDist return aDist < bDist
})!.value) })!.value)
} else { } else {
self._expiresIn = State(wrappedValue: 24 * 60 * 60) self._expiresIn = State(wrappedValue: EditedFilter.defaultExpiresInForExpired)
} }
} }
@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
@ -158,7 +154,7 @@ struct EditFilterView: View {
Button(create ? "Create" : "Save") { Button(create ? "Create" : "Save") {
saveFilter() saveFilter()
} }
.disabled(!filter.isValid(for: mastodonController) || !edited) .disabled(!filter.isValid(for: mastodonController) || (!edited && originallyExpired))
} }
} }
} }
@ -167,6 +163,12 @@ 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) EditFilterView(filter: EditedFilter(), create: true, originallyExpired: false)
} 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) EditFilterView(filter: EditedFilter(filter), create: false, originallyExpired: filter.expiresAt != nil && filter.expiresAt! <= Date())
} label: { } label: {
FilterRow(filter: filter) FilterRow(filter: filter)
} }

View File

@ -14,6 +14,7 @@ struct BehaviorPrefsView: View {
var body: some View { var body: some View {
List { List {
untitledSection untitledSection
timelineSection
linksSection linksSection
contentWarningsSection contentWarningsSection
} }
@ -28,6 +29,16 @@ 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 total favorite and reblog counts are shown for the main post in conversations.")) { Section(footer: Text("Control whether the focused post in the conversation view shows total favorite and reblog counts.")) {
Toggle(isOn: $preferences.showFavoriteAndReblogCounts) { Toggle(isOn: $preferences.showFavoriteAndReblogCounts) {
Text("Show Favorite and Reblog Counts") Text("Favorite and Reblog Counts in Conversations")
} }
} }
} }

View File

@ -15,6 +15,8 @@ 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
@ -100,6 +102,36 @@ 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,6 +25,8 @@ 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
@ -115,6 +117,7 @@ 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
@ -134,8 +137,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
} }
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { [unowned self] 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 {
@ -153,7 +159,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, nil)) return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result, attributedString))
case .hide: case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
} }
@ -192,13 +198,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
if doRestore() { if doRestore() {
Task { Task {
await checkPresent() await checkPresent(jumpImmediately: false)
} }
} else { } else {
Task { Task {
await controller.loadInitial() await controller.loadInitial()
} }
} }
} else {
checkPresentIfEnoughTimeElapsed()
} }
} }
@ -215,6 +223,12 @@ 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
@ -284,7 +298,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
private func doRestore() -> Bool { private func doRestore() -> Bool {
guard let activity = activityToRestore else { guard let activity = activityToRestore,
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 {
@ -292,6 +307,10 @@ 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()
@ -371,9 +390,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
view.window?.windowScene == scene else { view.window?.windowScene == scene else {
return return
} }
Task { checkPresentIfEnoughTimeElapsed()
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() {
@ -395,21 +421,43 @@ 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 {
insertPresentItemsIfNecessary(presentItems) insertPresentItemsAndShowJumpToast(presentItems)
} }
} }
} }
} }
private func checkPresent() async { private func checkPresentIfEnoughTimeElapsed() {
if case .idle = controller.state, guard let disappearedAt,
let presentItems = try? await loadInitial() { -disappearedAt.timeIntervalSinceNow > 60 * 60 /* 1 hour */ else {
insertPresentItemsIfNecessary(presentItems) return
}
self.disappearedAt = nil
Task {
await checkPresent(jumpImmediately: false)
} }
} }
private func insertPresentItemsIfNecessary(_ presentItems: [String]) { func checkPresent(jumpImmediately: Bool) async {
let snapshot = dataSource.snapshot() if case .idle = controller.state,
let presentItems = try? await loadInitial(),
!presentItems.isEmpty {
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]) {
var snapshot = dataSource.snapshot()
guard snapshot.indexOfSection(.statuses) != nil else { guard snapshot.indexOfSection(.statuses) != nil else {
return return
} }
@ -417,13 +465,38 @@ 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
// create a new snapshot to reset the timeline to the "present" state // remove any existing gap, if there is one
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() if let index = currentItems.lastIndex(of: .gap) {
snapshot.appendSections([.statuses]) snapshot.deleteItems(Array(currentItems[index...]))
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)
var config = ToastConfiguration(title: "Jump to present") if applySnapshotBeforeScrolling {
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
@ -440,19 +513,24 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} else { } else {
origItemAtTop = nil origItemAtTop = nil
} }
self.dataSource.apply(snapshot, animatingDifferences: true) { // 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
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 = 4 config.dismissAutomaticallyAfter = 2
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(snapshot, maintainingScreenPosition: offset, ofItem: item) self.applySnapshot(origSnapshot, maintainingScreenPosition: offset, ofItem: item)
} else { } else {
self.dataSource.apply(origSnapshot, animatingDifferences: false) self.dataSource.apply(origSnapshot, animatingDifferences: false)
} }
@ -477,10 +555,12 @@ 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)
snapshotView.layer.zPosition = 1000 if let snapshotView {
snapshotView.frame = view.bounds snapshotView.layer.zPosition = 1000
view.addSubview(snapshotView) snapshotView.frame = view.bounds
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) {
@ -489,12 +569,17 @@ 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)
@ -502,12 +587,13 @@ 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,6 +45,22 @@ 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,6 +32,9 @@ 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,4 +379,13 @@ 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,6 +83,10 @@ 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.font = TimelineStatusCollectionViewCell.contentFont $0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle $0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
$0.setContentHuggingPriority(.defaultLow, for: .vertical) $0.setContentHuggingPriority(.defaultLow, for: .vertical)
} }