Compare commits
20 Commits
e49725e06d
...
13d649bace
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 13d649bace | |
Shadowfacts | bebe563e8f | |
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 |
22
CHANGELOG.md
22
CHANGELOG.md
|
@ -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).
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,8 +33,12 @@ class EditedFilter: ObservableObject {
|
||||||
self.title = mo.title
|
self.title = mo.title
|
||||||
self.contexts = mo.contexts
|
self.contexts = mo.contexts
|
||||||
if let expiresAt = mo.expiresAt {
|
if let expiresAt = mo.expiresAt {
|
||||||
|
if expiresAt <= Date() {
|
||||||
|
expiresIn = EditedFilter.defaultExpiresInForExpired
|
||||||
|
} else {
|
||||||
expiresIn = expiresAt.timeIntervalSinceNow
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,9 +91,12 @@ class TrendingStatusesViewController: UIViewController {
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
|
if !loaded {
|
||||||
|
loaded = true
|
||||||
await loadTrendingStatuses()
|
await loadTrendingStatuses()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadTrendingStatuses() async {
|
private func loadTrendingStatuses() async {
|
||||||
let statuses: [Status]
|
let statuses: [Status]
|
||||||
|
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ struct BehaviorPrefsView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
untitledSection
|
untitledSection
|
||||||
|
timelineSection
|
||||||
linksSection
|
linksSection
|
||||||
contentWarningsSection
|
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 {
|
private var linksSection: some View {
|
||||||
Section(header: Text("Links")) {
|
Section(header: Text("Links")) {
|
||||||
Toggle(isOn: $preferences.openLinksInApps) {
|
Toggle(isOn: $preferences.openLinksInApps) {
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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() {
|
||||||
|
guard let disappearedAt,
|
||||||
|
-disappearedAt.timeIntervalSinceNow > 60 * 60 /* 1 hour */ else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.disappearedAt = nil
|
||||||
|
Task {
|
||||||
|
await checkPresent(jumpImmediately: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPresent(jumpImmediately: Bool) async {
|
||||||
if case .idle = controller.state,
|
if case .idle = controller.state,
|
||||||
let presentItems = try? await loadInitial() {
|
let presentItems = try? await loadInitial(),
|
||||||
insertPresentItemsIfNecessary(presentItems)
|
!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 insertPresentItemsIfNecessary(_ presentItems: [String]) {
|
private func insertPresentItemsAndShowJumpToast(_ presentItems: [String]) {
|
||||||
let snapshot = dataSource.snapshot()
|
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)
|
|
||||||
|
|
||||||
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.edge = .top
|
||||||
config.systemImageName = "arrow.up"
|
config.systemImageName = "arrow.up"
|
||||||
config.dismissAutomaticallyAfter = 4
|
config.dismissAutomaticallyAfter = 4
|
||||||
|
@ -441,18 +514,23 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
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)
|
||||||
|
if let snapshotView {
|
||||||
snapshotView.layer.zPosition = 1000
|
snapshotView.layer.zPosition = 1000
|
||||||
snapshotView.frame = view.bounds
|
snapshotView.frame = view.bounds
|
||||||
view.addSubview(snapshotView)
|
view.addSubview(snapshotView)
|
||||||
|
}
|
||||||
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||||
if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) {
|
if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) {
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue