Compare commits
18 Commits
e49725e06d
...
4be2258882
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 4be2258882 | |
Shadowfacts | 40ff8d0a2a | |
Shadowfacts | 0dcb7e71c4 | |
Shadowfacts | 08878f2fb9 | |
Shadowfacts | 3ea7e1057b | |
Shadowfacts | fc8fcb76fd | |
Shadowfacts | eac2a9b19f | |
Shadowfacts | 0ce57d1308 | |
Shadowfacts | 97dec0f9d2 | |
Shadowfacts | b64c748b73 | |
Shadowfacts | 77ab2c3753 | |
Shadowfacts | b90262bfd0 | |
Shadowfacts | 581f4b24bd | |
Shadowfacts | 5f3d9da9f8 | |
Shadowfacts | 41775e5d19 | |
Shadowfacts | 044d34d20f | |
Shadowfacts | f1b1732e5c | |
Shadowfacts | 1da2b17a76 |
|
@ -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).
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ struct BehaviorPrefsView: View {
|
|||
var body: some View {
|
||||
List {
|
||||
untitledSection
|
||||
timelineSection
|
||||
linksSection
|
||||
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 {
|
||||
Section(header: Text("Links")) {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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...]))
|
||||
|
||||
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.systemImageName = "arrow.up"
|
||||
config.dismissAutomaticallyAfter = 4
|
||||
|
@ -440,19 +513,24 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
} else {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue