Compare commits

...

18 Commits

Author SHA1 Message Date
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 291 additions and 53 deletions

View File

@ -1,5 +1,8 @@
# Changelog
## 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)
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_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2359,7 +2359,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2509,7 +2509,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2538,7 +2538,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2648,7 +2648,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2675,7 +2675,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 49;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;

View File

@ -8,6 +8,7 @@
import Foundation
import Pachyderm
import Sentry
struct InstanceFeatures {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
@ -136,6 +137,8 @@ struct InstanceFeatures {
}
maxStatusChars = instance.maxStatusCharacters ?? 500
setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
}
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,31 @@ class MastodonCachePersistentStore: NSPersistentContainer {
persistentStoreDescriptions = [storeDescription]
} else {
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 else {
continue
}
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)
}
}
}
}
}
loadPersistentStores { (description, error) in

View File

@ -47,6 +47,19 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<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>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>

View File

@ -10,6 +10,8 @@ import Foundation
import Pachyderm
class EditedFilter: ObservableObject {
static let defaultExpiresInForExpired: TimeInterval = 24 * 60 * 60
let id: String?
@Published var title: String?
@Published var contexts: [FilterV1.Context]
@ -31,7 +33,11 @@ class EditedFilter: ObservableObject {
self.title = mo.title
self.contexts = mo.contexts
if let expiresAt = mo.expiresAt {
expiresIn = expiresAt.timeIntervalSinceNow
if expiresAt <= Date() {
expiresIn = EditedFilter.defaultExpiresInForExpired
} else {
expiresIn = expiresAt.timeIntervalSinceNow
}
}
self.keywords = mo.keywordMOs.map {
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.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
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.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(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
@ -156,6 +158,7 @@ class Preferences: Codable, ObservableObject {
@Published var collapseLongPosts = true
@Published var oppositeCollapseKeywords: [String] = []
@Published var confirmBeforeReblog = false
@Published var timelineStateRestoration = true
// MARK: Digital Wellness
@Published var showFavoriteAndReblogCounts = true
@ -199,6 +202,7 @@ class Preferences: Codable, ObservableObject {
case collapseLongPosts
case oppositeCollapseKeywords
case confirmBeforeReblog
case timelineStateRestoration
case showFavoriteAndReblogCounts
case defaultNotificationsType

View File

@ -18,6 +18,8 @@ class TrendingStatusesViewController: UIViewController {
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var loaded = false
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
@ -89,7 +91,10 @@ class TrendingStatusesViewController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: false)
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)!) }
}()
@ObservedObject var filter: EditedFilter
let create: Bool
@ObservedObject private var filter: EditedFilter
private let create: Bool
private let originallyExpired: Bool
@EnvironmentObject private var mastodonController: MastodonController
@Environment(\.dismiss) private var dismiss
@State private var expiresIn: TimeInterval
@State private var originalFilter: EditedFilter
@State private var edited = false
@State private var isSaving = false
@State private var saveError: (any Error)?
init(filter: EditedFilter, create: Bool) {
init(filter: EditedFilter, create: Bool, originallyExpired: Bool) {
self.filter = filter
self.create = create
self.originallyExpired = originallyExpired
self._originalFilter = State(wrappedValue: EditedFilter(copying: filter))
if let expiresIn = filter.expiresIn {
self._expiresIn = State(wrappedValue: Self.expiresInOptions.min(by: { a, b in
@ -47,17 +50,10 @@ struct EditFilterView: View {
return aDist < bDist
})!.value)
} 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> {
Binding {
filter.expiresIn != nil
@ -158,7 +154,7 @@ struct EditFilterView: View {
Button(create ? "Create" : "Save") {
saveFilter()
}
.disabled(!filter.isValid(for: mastodonController) || !edited)
.disabled(!filter.isValid(for: mastodonController) || (!edited && originallyExpired))
}
}
}
@ -167,6 +163,12 @@ struct EditFilterView: View {
}, message: { error in
Text(error.localizedDescription)
})
.onChange(of: expiresIn, perform: { newValue in
edited = true
if expires.wrappedValue {
filter.expiresIn = newValue
}
})
.onReceive(filter.objectWillChange, perform: { _ in
edited = true
})

View File

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

View File

@ -14,6 +14,7 @@ struct BehaviorPrefsView: View {
var body: some View {
List {
untitledSection
timelineSection
linksSection
contentWarningsSection
}
@ -29,6 +30,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 {
Section(header: Text("Links")) {
Toggle(isOn: $preferences.openLinksInApps) {

View File

@ -24,9 +24,9 @@ struct WellnessPrefsView: 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) {
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 chevronView = AnimatingChevronView()
var fillGap: ((TimelineGapDirection) async -> Void)?
override var isHighlighted: Bool {
didSet {
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 {

View File

@ -25,6 +25,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private var contentOffsetObservation: NSKeyValueObservation?
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!) {
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(sceneDidEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil)
}
// 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 gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
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
guard case .public(let local) = timeline else {
@ -153,7 +159,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
let (result, attributedString) = filterResult(state: filterState, statusID: id)
switch result {
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:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
}
@ -192,13 +198,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if case .notLoadedInitial = controller.state {
if doRestore() {
Task {
await checkPresent()
await checkPresent(jumpImmediately: false)
}
} else {
Task {
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? {
guard isViewLoaded else {
return nil
@ -284,7 +298,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
private func doRestore() -> Bool {
guard let activity = activityToRestore else {
guard let activity = activityToRestore,
Preferences.shared.timelineStateRestoration else {
return false
}
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
@ -292,6 +307,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return false
}
activityToRestore = nil
let existingStatuses = statusIDs.filter { mastodonController.persistentContainer.status(for: $0) != nil }
guard !existingStatuses.isEmpty else {
return false
}
loadViewIfNeeded()
controller.restoreInitial {
var snapshot = dataSource.snapshot()
@ -371,9 +390,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
view.window?.windowScene == scene else {
return
}
Task {
await checkPresent()
checkPresentIfEnoughTimeElapsed()
}
@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() {
@ -395,21 +421,43 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
let (_, presentItems) = await (loadNewerAndEndRefreshing(), try? loadInitial())
if let presentItems, !presentItems.isEmpty {
insertPresentItemsIfNecessary(presentItems)
insertPresentItemsAndShowJumpToast(presentItems)
}
}
}
}
private func checkPresent() async {
if case .idle = controller.state,
let presentItems = try? await loadInitial() {
insertPresentItemsIfNecessary(presentItems)
private func checkPresentIfEnoughTimeElapsed() {
guard let disappearedAt,
-disappearedAt.timeIntervalSinceNow > 60 * 60 /* 1 hour */ else {
return
}
self.disappearedAt = nil
Task {
await checkPresent(jumpImmediately: false)
}
}
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
let snapshot = dataSource.snapshot()
func checkPresent(jumpImmediately: Bool) async {
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 {
return
}
@ -417,13 +465,38 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
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
!presentItems.contains(firstID) {
let applySnapshotBeforeScrolling: Bool
// create a new snapshot to reset the timeline to the "present" state
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
// remove any existing gap, if there is one
if let index = currentItems.lastIndex(of: .gap) {
snapshot.deleteItems(Array(currentItems[index...]))
var config = ToastConfiguration(title: "Jump to present")
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 {
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.systemImageName = "arrow.up"
config.dismissAutomaticallyAfter = 4
@ -441,18 +514,23 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
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)
var config = ToastConfiguration(title: "Go back")
var config = ToastConfiguration(title: "Go Back")
config.edge = .top
config.systemImageName = "arrow.down"
config.dismissAutomaticallyAfter = 4
config.dismissAutomaticallyAfter = 2
config.action = { [unowned self] toast in
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
if let (item, offset) = origItemAtTop {
self.applySnapshot(snapshot, maintainingScreenPosition: offset, ofItem: item)
self.applySnapshot(origSnapshot, maintainingScreenPosition: offset, ofItem: item)
} else {
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) {
// 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)!
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)
if let snapshotView {
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
}
dataSource.apply(snapshot, animatingDifferences: false) {
if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) {
@ -489,12 +569,17 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
var cur = indexPathOfItemToMaintain
var amountScrolledUp: CGFloat = 0
var first = true
while true {
if cur.row <= 0 {
break
}
if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain),
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
}
cur = IndexPath(row: cur.row - 1, section: cur.section)
@ -502,12 +587,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
self.collectionView.layoutIfNeeded()
let attrs = self.collectionView.layoutAttributesForItem(at: cur)!
amountScrolledUp += attrs.size.height
first = false
}
self.collectionView.contentOffset.y += amountScrolledUp
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))
filtersItem.accessibilityLabel = "Filters"
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) {

View File

@ -32,6 +32,9 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
// before the view has necessarily loaded
segmentedControl = UISegmentedControl(items: titles)
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
}

View File

@ -379,4 +379,13 @@ enum TimelineGapDirection {
case below
/// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap.
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.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)
}

View File

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