Compare commits
17 Commits
cced930549
...
e522e30ce5
Author | SHA1 | Date |
---|---|---|
Shadowfacts | e522e30ce5 | |
Shadowfacts | c73784aa81 | |
Shadowfacts | 7affa09e5e | |
Shadowfacts | 7435d02f6e | |
Shadowfacts | 2467297f04 | |
Shadowfacts | cf317e15e9 | |
Shadowfacts | bcae60316b | |
Shadowfacts | 1a2fa10708 | |
Shadowfacts | f79c2feea6 | |
Shadowfacts | 7ec87d7853 | |
Shadowfacts | f5704e561b | |
Shadowfacts | d6faf3a37b | |
Shadowfacts | b0a6952643 | |
Shadowfacts | 06b58cfb9c | |
Shadowfacts | afcec24f86 | |
Shadowfacts | 3f90a0df04 | |
Shadowfacts | 395ce6523d |
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -1,5 +1,23 @@
|
|||
# Changelog
|
||||
|
||||
## 2024.1 (116)
|
||||
Features/Improvements:
|
||||
- Display message on empty list timelines
|
||||
- Add preference to display badge for attachments that lack alt text
|
||||
- Mark notifications as read on the Mastodon web frontend once displayed
|
||||
- iPadOS: Support tapping the selected sidebar item to scroll to top
|
||||
|
||||
Bugfixes:
|
||||
- Fix playing back GIFVs preventing the device sleeping
|
||||
- Fix incorrect cell separator insets followers/following lists
|
||||
- Fix memory leak in attachments gallery
|
||||
- Fix notifications tab not scrolling to top when tab bar item tapped
|
||||
- Fix Trending Hashtags screen not clearing selection
|
||||
- Fix fast account switcher overlapping sensor housing on landscape iPhones
|
||||
- Fix Edit List screen not updating when accounts are added/removed
|
||||
- Fix changing List reply policy not refreshing list timeline
|
||||
- macOS: Fix certain gallery attachments being incorrectly sized/positioned
|
||||
|
||||
## 2024.1 (115)
|
||||
Features/Improvements:
|
||||
- Rewrite attachment gallery
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public protocol GalleryContentViewControllerContainer {
|
||||
public protocol GalleryContentViewControllerContainer: AnyObject {
|
||||
var galleryControlsVisible: Bool { get }
|
||||
|
||||
func setGalleryContentLoading(_ loading: Bool)
|
||||
|
|
|
@ -10,7 +10,7 @@ import UIKit
|
|||
@MainActor
|
||||
class GalleryDismissInteraction: NSObject {
|
||||
|
||||
private let viewController: GalleryViewController
|
||||
private unowned let viewController: GalleryViewController
|
||||
|
||||
private var content: GalleryContentViewController?
|
||||
private var origContentFrameInGallery: CGRect?
|
||||
|
|
|
@ -43,6 +43,7 @@ class GalleryItemViewController: UIViewController {
|
|||
private(set) var controlsVisible: Bool = true
|
||||
private(set) var scrollAndZoomEnabled = true
|
||||
|
||||
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||
return !controlsVisible
|
||||
}
|
||||
|
@ -219,6 +220,12 @@ class GalleryItemViewController: UIViewController {
|
|||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
// When the scrollView size changes, make sure the zoom scale is up-to-date since it depends on the scrollView's bounds.
|
||||
// This might also fix an issue on macOS (Designed for iPad) where the content isn't placed correctly. See #446
|
||||
if scrollViewSizeForLastZoomScaleUpdate != scrollView.bounds.size {
|
||||
scrollViewSizeForLastZoomScaleUpdate = scrollView.bounds.size
|
||||
updateZoomScale(resetZoom: true)
|
||||
}
|
||||
centerContent()
|
||||
}
|
||||
|
||||
|
@ -303,6 +310,8 @@ class GalleryItemViewController: UIViewController {
|
|||
}
|
||||
|
||||
func updateZoomScale(resetZoom: Bool) {
|
||||
scrollView.contentSize = content.contentSize
|
||||
|
||||
guard scrollAndZoomEnabled else {
|
||||
scrollView.maximumZoomScale = 1
|
||||
scrollView.minimumZoomScale = 1
|
||||
|
|
|
@ -7,26 +7,53 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct TimelineMarkers: Decodable, Sendable {
|
||||
public let home: Marker?
|
||||
public let notifications: Marker?
|
||||
public struct TimelineMarkers {
|
||||
private init() {}
|
||||
|
||||
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> {
|
||||
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue))
|
||||
public static func request<T: TimelineMarkerType>(timeline: T) -> Request<TimelineMarker<T.Payload>> {
|
||||
Request(method: .get, path: "/api/v1/markers", queryParameters: ["timeline[]" => T.name])
|
||||
}
|
||||
|
||||
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> {
|
||||
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
||||
"\(timeline.rawValue)[last_read_id]" => lastReadID,
|
||||
public static func update<T: TimelineMarkerType>(timeline: T, lastReadID: String) -> Request<Empty> {
|
||||
Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
||||
"\(T.name)[last_read_id]" => lastReadID
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
public enum Timeline: String {
|
||||
case home
|
||||
case notifications
|
||||
public struct TimelineMarker<Payload: TimelineMarkerTypePayload>: Decodable, Sendable {
|
||||
let payload: Payload
|
||||
|
||||
public var lastReadID: String {
|
||||
payload.payload.lastReadID
|
||||
}
|
||||
public var version: Int {
|
||||
payload.payload.version
|
||||
}
|
||||
public var updatedAt: Date {
|
||||
payload.payload.updatedAt
|
||||
}
|
||||
|
||||
public struct Marker: Decodable, Sendable {
|
||||
public init(from decoder: any Decoder) throws {
|
||||
self.payload = try Payload(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol TimelineMarkerTypePayload: Decodable, Sendable {
|
||||
var payload: MarkerPayload { get }
|
||||
}
|
||||
|
||||
public struct HomeMarkerPayload: TimelineMarkerTypePayload {
|
||||
public var home: MarkerPayload
|
||||
public var payload: MarkerPayload { home }
|
||||
}
|
||||
|
||||
public struct NotificationsMarkerPayload: TimelineMarkerTypePayload {
|
||||
public var notifications: MarkerPayload
|
||||
public var payload: MarkerPayload { notifications }
|
||||
}
|
||||
|
||||
public struct MarkerPayload: Decodable, Sendable {
|
||||
public let lastReadID: String
|
||||
public let version: Int
|
||||
public let updatedAt: Date
|
||||
|
@ -36,5 +63,27 @@ public struct TimelineMarkers: Decodable, Sendable {
|
|||
case version
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol TimelineMarkerType {
|
||||
static var name: String { get }
|
||||
associatedtype Payload: TimelineMarkerTypePayload
|
||||
}
|
||||
|
||||
extension TimelineMarkerType where Self == HomeMarker {
|
||||
public static var home: Self { .init() }
|
||||
}
|
||||
|
||||
extension TimelineMarkerType where Self == NotificationsMarker {
|
||||
public static var notifications: Self { .init() }
|
||||
}
|
||||
|
||||
public struct HomeMarker: TimelineMarkerType {
|
||||
public typealias Payload = HomeMarkerPayload
|
||||
public static var name: String { "home" }
|
||||
}
|
||||
|
||||
public struct NotificationsMarker: TimelineMarkerType {
|
||||
public typealias Payload = NotificationsMarkerPayload
|
||||
public static var name: String { "notifications" }
|
||||
}
|
||||
|
|
|
@ -87,6 +87,7 @@ public final class Preferences: Codable, ObservableObject {
|
|||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
|
||||
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
|
||||
self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false
|
||||
|
||||
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||
|
@ -145,6 +146,7 @@ public final class Preferences: Codable, ObservableObject {
|
|||
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
||||
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
|
||||
try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges)
|
||||
try container.encode(attachmentAltBadgeInverted, forKey: .attachmentAltBadgeInverted)
|
||||
|
||||
try container.encode(openLinksInApps, forKey: .openLinksInApps)
|
||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
||||
|
@ -211,6 +213,7 @@ public final class Preferences: Codable, ObservableObject {
|
|||
@Published public var automaticallyPlayGifs = true
|
||||
@Published public var showUncroppedMediaInline = true
|
||||
@Published public var showAttachmentBadges = true
|
||||
@Published public var attachmentAltBadgeInverted = false
|
||||
|
||||
// MARK: Behavior
|
||||
@Published public var openLinksInApps = true
|
||||
|
@ -274,6 +277,7 @@ public final class Preferences: Codable, ObservableObject {
|
|||
case automaticallyPlayGifs
|
||||
case showUncroppedMediaInline
|
||||
case showAttachmentBadges
|
||||
case attachmentAltBadgeInverted
|
||||
|
||||
case openLinksInApps
|
||||
case useInAppSafari
|
||||
|
|
|
@ -52,6 +52,12 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
|
|||
config.topSeparatorVisibility = .hidden
|
||||
config.bottomSeparatorVisibility = .hidden
|
||||
}
|
||||
if config.topSeparatorInsets != .zero {
|
||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||
}
|
||||
if config.bottomSeparatorInsets != .zero {
|
||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||
}
|
||||
return config
|
||||
}
|
||||
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
|
||||
|
|
|
@ -33,6 +33,8 @@ class AccountListViewController: UIViewController, CollectionViewController {
|
|||
override func loadView() {
|
||||
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
config.backgroundColor = .appGroupedBackground
|
||||
config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||
config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
|
||||
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
|
||||
section.readableContentInset(in: environment)
|
||||
|
|
|
@ -11,11 +11,11 @@ import Pachyderm
|
|||
import WebURLFoundationExtras
|
||||
import Combine
|
||||
|
||||
class TrendingHashtagsViewController: UIViewController {
|
||||
class TrendingHashtagsViewController: UIViewController, CollectionViewController {
|
||||
|
||||
private let mastodonController: MastodonController
|
||||
|
||||
private var collectionView: UICollectionView!
|
||||
private(set) var collectionView: UICollectionView!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
private var state = State.unloaded
|
||||
|
@ -84,6 +84,8 @@ class TrendingHashtagsViewController: UIViewController {
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
clearSelectionOnAppear(animated: animated)
|
||||
|
||||
Task {
|
||||
await loadInitial()
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
|
@ -32,7 +32,6 @@
|
|||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="lYU-Bb-3Wi" firstAttribute="top" secondItem="1Gd-Da-Vab" secondAttribute="topMargin" placeholder="YES" id="KQs-d5-U3f"/>
|
||||
<constraint firstAttribute="trailing" secondItem="lYU-Bb-3Wi" secondAttribute="trailingMargin" constant="8" id="UZh-xR-XVt"/>
|
||||
<constraint firstAttribute="bottomMargin" secondItem="lYU-Bb-3Wi" secondAttribute="bottom" placeholder="YES" id="j6f-r5-NNI"/>
|
||||
<constraint firstItem="lYU-Bb-3Wi" firstAttribute="leading" secondItem="1Gd-Da-Vab" secondAttribute="leading" constant="8" id="sae-ga-MGE"/>
|
||||
</constraints>
|
||||
|
@ -47,6 +46,7 @@
|
|||
<constraint firstItem="5fd-Ni-Owc" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="phf-PC-bdH"/>
|
||||
<constraint firstItem="5fd-Ni-Owc" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" id="rz7-cQ-PIC"/>
|
||||
<constraint firstAttribute="bottom" secondItem="5fd-Ni-Owc" secondAttribute="bottom" id="sHl-iD-kGi"/>
|
||||
<constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="lYU-Bb-3Wi" secondAttribute="trailing" constant="8" id="snX-cx-wq6"/>
|
||||
</constraints>
|
||||
<point key="canvasLocation" x="140.57971014492756" y="144.64285714285714"/>
|
||||
</view>
|
||||
|
|
|
@ -70,7 +70,7 @@ class FallbackGalleryNavigationController: UINavigationController, GalleryConten
|
|||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
|
||||
var contentSize: CGSize {
|
||||
.zero
|
||||
|
|
|
@ -59,7 +59,7 @@ class GifvGalleryContentViewController: UIViewController, GalleryContentViewCont
|
|||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
|
||||
var contentSize: CGSize {
|
||||
controller.item.presentationSize
|
||||
|
|
|
@ -114,7 +114,7 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
|
|||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
|
||||
var contentSize: CGSize {
|
||||
image.size
|
||||
|
|
|
@ -13,7 +13,7 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
|
|||
private let provider: () async -> (any GalleryContentViewController)?
|
||||
private var wrapped: (any GalleryContentViewController)!
|
||||
|
||||
var container: GalleryContentViewControllerContainer?
|
||||
weak var container: GalleryContentViewControllerContainer?
|
||||
|
||||
var contentSize: CGSize {
|
||||
wrapped?.contentSize ?? .zero
|
||||
|
|
|
@ -132,7 +132,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
|||
|
||||
// MARK: GalleryContentViewController
|
||||
|
||||
var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
|
||||
|
||||
var contentSize: CGSize {
|
||||
item.presentationSize
|
||||
|
|
|
@ -17,7 +17,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
|
|||
|
||||
private var state = State.unloaded
|
||||
|
||||
private(set) var changedAccounts = false
|
||||
private(set) var shouldReloadListTimeline = false
|
||||
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
var collectionView: UICollectionView! { view as? UICollectionView }
|
||||
|
@ -186,7 +186,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
|
|||
|
||||
@MainActor
|
||||
private func loadAccounts() async {
|
||||
guard state == .unloaded else { return }
|
||||
guard state == .unloaded || state == .loaded else { return }
|
||||
|
||||
state = .loading
|
||||
|
||||
|
@ -278,7 +278,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
|
|||
}
|
||||
|
||||
private func addAccount(id: String) async {
|
||||
changedAccounts = true
|
||||
shouldReloadListTimeline = true
|
||||
do {
|
||||
let req = List.add(list.id, accounts: [id])
|
||||
_ = try await mastodonController.run(req)
|
||||
|
@ -294,11 +294,19 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
|
|||
}
|
||||
|
||||
private func removeAccount(id: String) async {
|
||||
changedAccounts = true
|
||||
shouldReloadListTimeline = true
|
||||
do {
|
||||
let request = List.remove(list.id, accounts: [id])
|
||||
_ = try await mastodonController.run(request)
|
||||
await self.loadAccounts()
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
if snapshot.itemIdentifiers.contains(.account(id: id)) {
|
||||
snapshot.deleteItems([.account(id: id)])
|
||||
await MainActor.run {
|
||||
dataSource.apply(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Removing Account", in: self) { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
|
@ -309,6 +317,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
|
|||
}
|
||||
|
||||
private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) {
|
||||
shouldReloadListTimeline = true
|
||||
Task {
|
||||
let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
|
||||
await service.run(replyPolicy: replyPolicy)
|
||||
|
|
|
@ -16,6 +16,8 @@ class ListTimelineViewController: TimelineViewController {
|
|||
|
||||
var presentEditOnAppear = false
|
||||
|
||||
private var noContentView: UIStackView!
|
||||
|
||||
private var listRenamedCancellable: AnyCancellable?
|
||||
|
||||
init(for list: List, mastodonController: MastodonController) {
|
||||
|
@ -53,6 +55,39 @@ class ListTimelineViewController: TimelineViewController {
|
|||
}
|
||||
}
|
||||
|
||||
private func createNoContentView() {
|
||||
let title = UILabel()
|
||||
title.textColor = .secondaryLabel
|
||||
title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
|
||||
title.adjustsFontForContentSizeCategory = true
|
||||
title.text = "No Posts"
|
||||
|
||||
let subtitle = UILabel()
|
||||
subtitle.textColor = .secondaryLabel
|
||||
subtitle.font = .preferredFont(forTextStyle: .body)
|
||||
subtitle.adjustsFontForContentSizeCategory = true
|
||||
subtitle.numberOfLines = 0
|
||||
subtitle.textAlignment = .center
|
||||
subtitle.text = "This list doesn't currently have any posts. Edit it to add accounts if necessary."
|
||||
|
||||
noContentView = UIStackView(arrangedSubviews: [
|
||||
title,
|
||||
subtitle,
|
||||
])
|
||||
noContentView.axis = .vertical
|
||||
noContentView.spacing = 8
|
||||
noContentView.alignment = .center
|
||||
noContentView.isAccessibilityElement = true
|
||||
noContentView.accessibilityLabel = subtitle.text
|
||||
noContentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(noContentView)
|
||||
NSLayoutConstraint.activate([
|
||||
noContentView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
noContentView.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1),
|
||||
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: noContentView.trailingAnchor, multiplier: 1),
|
||||
])
|
||||
}
|
||||
|
||||
private func listChanged() {
|
||||
title = list.title
|
||||
}
|
||||
|
@ -61,10 +96,23 @@ class ListTimelineViewController: TimelineViewController {
|
|||
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
|
||||
editListAccountsController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
|
||||
let navController = UINavigationController(rootViewController: editListAccountsController)
|
||||
navController.sheetPresentationController?.delegate = self
|
||||
present(navController, animated: animated)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
private func reloadIfNecessary(editViewController: EditListAccountsViewController) {
|
||||
guard editViewController.shouldReloadListTimeline else {
|
||||
return
|
||||
}
|
||||
noContentView?.removeFromSuperview()
|
||||
noContentView = nil
|
||||
Task {
|
||||
applyInitialSnapshot()
|
||||
await controller.loadInitial()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
@objc func editListButtonPressed() {
|
||||
presentEdit(animated: true)
|
||||
|
@ -75,12 +123,28 @@ class ListTimelineViewController: TimelineViewController {
|
|||
|
||||
dismiss(animated: true)
|
||||
|
||||
if presented?.changedAccounts == true {
|
||||
Task {
|
||||
applyInitialSnapshot()
|
||||
await controller.loadInitial()
|
||||
}
|
||||
if let presented {
|
||||
self.reloadIfNecessary(editViewController: presented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: TimelineLikeControllerDelegate
|
||||
|
||||
override func handleReplaceAllItems(_ timelineItems: [String]) async {
|
||||
if timelineItems.isEmpty {
|
||||
createNoContentView()
|
||||
}
|
||||
await super.handleReplaceAllItems(timelineItems)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ListTimelineViewController: UISheetPresentationControllerDelegate {
|
||||
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
||||
guard let nav = presentationController.presentedViewController as? UINavigationController,
|
||||
let edit = nav.viewControllers.first as? EditListAccountsViewController else {
|
||||
return
|
||||
}
|
||||
reloadIfNecessary(editViewController: edit)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ protocol MainSidebarViewControllerDelegate: AnyObject {
|
|||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
|
||||
}
|
||||
|
||||
class MainSidebarViewController: UIViewController {
|
||||
|
@ -451,7 +452,9 @@ extension MainSidebarViewController: UICollectionViewDelegate {
|
|||
return
|
||||
}
|
||||
itemLastSelectedTimestamps[item] = Date()
|
||||
if [MainSidebarViewController.Item.tab(.compose), .addList, .addSavedHashtag, .addSavedInstance].contains(item) {
|
||||
if previouslySelectedItem == item {
|
||||
sidebarDelegate?.sidebar(self, scrollToTopFor: item)
|
||||
} else if [MainSidebarViewController.Item.tab(.compose), .addList, .addSavedHashtag, .addSavedInstance].contains(item) {
|
||||
if let previous = previouslySelectedItem, let indexPath = dataSource.indexPath(for: previous) {
|
||||
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
|
||||
}
|
||||
|
|
|
@ -478,6 +478,10 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
|||
}
|
||||
secondaryNavController.viewControllers = [viewController]
|
||||
}
|
||||
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item) {
|
||||
(secondaryNavController as? TabBarScrollableViewController)?.tabBarScrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension MainSidebarViewController.Item {
|
||||
|
|
|
@ -241,6 +241,7 @@ extension MainTabBarViewController {
|
|||
#if !os(visionOS)
|
||||
extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
|
||||
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) {
|
||||
fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(fastAccountSwitcher.view)
|
||||
NSLayoutConstraint.activate([
|
||||
fastAccountSwitcher.accountsStack.bottomAnchor.constraint(equalTo: fastAccountSwitcher.view.bottomAnchor),
|
||||
|
@ -249,6 +250,10 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
|
|||
fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: tabBar.topAnchor),
|
||||
|
||||
// The safe area insets don't automatically propagate for some reason, so do it ourselves.
|
||||
fastAccountSwitcher.view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
fastAccountSwitcher.view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,9 @@ import Combine
|
|||
#if canImport(Sentry)
|
||||
import Sentry
|
||||
#endif
|
||||
import OSLog
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationsCVC")
|
||||
|
||||
class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
|
||||
|
||||
|
@ -32,6 +35,9 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
|||
private var newer: RequestRange?
|
||||
private var older: RequestRange?
|
||||
|
||||
var updatesNotificationsMarker: Bool = false
|
||||
private var newestDisplayedNotification: Item?
|
||||
|
||||
init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) {
|
||||
self.allowedTypes = allowedTypes
|
||||
self.mastodonController = mastodonController
|
||||
|
@ -205,6 +211,12 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
|||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
updateNotificationsMarkerIfNecessary()
|
||||
}
|
||||
|
||||
@objc func refresh() {
|
||||
Task { @MainActor in
|
||||
if case .notLoadedInitial = controller.state {
|
||||
|
@ -311,6 +323,23 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
|||
await apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
|
||||
private func updateNotificationsMarkerIfNecessary() {
|
||||
guard updatesNotificationsMarker,
|
||||
case let .group(group, _, _) = newestDisplayedNotification,
|
||||
let notification = group.notifications.first else {
|
||||
return
|
||||
}
|
||||
logger.debug("Updating notifications marker with \(notification.id)")
|
||||
Task {
|
||||
let req = TimelineMarkers.update(timeline: .notifications, lastReadID: notification.id)
|
||||
do {
|
||||
_ = try await mastodonController.run(req)
|
||||
} catch {
|
||||
logger.error("Failed to update notifications marker: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension NotificationsCollectionViewController {
|
||||
|
@ -545,6 +574,21 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
|
|||
guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) else {
|
||||
return
|
||||
}
|
||||
|
||||
if updatesNotificationsMarker {
|
||||
let shouldUpdateNewestDisplayedNotification: Bool
|
||||
if let newestDisplayedNotification,
|
||||
let currentNewestIndexPath = dataSource.indexPath(for: newestDisplayedNotification) {
|
||||
shouldUpdateNewestDisplayedNotification = indexPath < currentNewestIndexPath
|
||||
} else {
|
||||
shouldUpdateNewestDisplayedNotification = true
|
||||
}
|
||||
if shouldUpdateNewestDisplayedNotification,
|
||||
let item = dataSource.itemIdentifier(for: indexPath) {
|
||||
newestDisplayedNotification = item
|
||||
}
|
||||
}
|
||||
|
||||
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
|
||||
if indexPath.row == itemsInSection - 1 {
|
||||
Task {
|
||||
|
@ -752,3 +796,22 @@ extension NotificationsCollectionViewController: StatusCollectionViewCellDelegat
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationsCollectionViewController: TabBarScrollableViewController {
|
||||
func tabBarScrollToTop() {
|
||||
collectionView.scrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationsCollectionViewController: StatusBarTappableViewController {
|
||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||
collectionView.scrollToTop()
|
||||
return .stop
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationsCollectionViewController: BackgroundableViewController {
|
||||
func sceneDidEnterBackground() {
|
||||
updateNotificationsMarkerIfNecessary()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
|
|||
let vc = NotificationsCollectionViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
|
||||
vc.title = page.title
|
||||
vc.userActivity = page.userActivity(accountID: mastodonController.accountInfo!.id)
|
||||
vc.updatesNotificationsMarker = page == .all
|
||||
return vc
|
||||
}
|
||||
|
||||
|
|
|
@ -44,8 +44,13 @@ struct MediaPrefsView: View {
|
|||
}
|
||||
|
||||
Toggle(isOn: $preferences.showAttachmentBadges) {
|
||||
Text("Show GIF/ALT Badges")
|
||||
Text("Show GIF/\(Text("Alt").font(.body.lowercaseSmallCaps())) Badges")
|
||||
}
|
||||
|
||||
Toggle(isOn: $preferences.attachmentAltBadgeInverted) {
|
||||
Text("Show Badge when Missing \(Text("Alt").font(.body.lowercaseSmallCaps()))")
|
||||
}
|
||||
.disabled(!preferences.showAttachmentBadges)
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
}
|
||||
|
|
|
@ -387,16 +387,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
_ = try await mastodonController.run(req)
|
||||
} catch {
|
||||
stateRestorationLogger.error("TimelineViewController: failed to update timeline marker: \(String(describing: error))")
|
||||
|
||||
#if canImport(Sentry)
|
||||
if let error = error as? Client.Error,
|
||||
case .networkError(_) = error.type {
|
||||
return
|
||||
}
|
||||
let event = Event(error: error)
|
||||
event.message = SentryMessage(formatted: "Failed to update timeline marker: \(String(describing: error))")
|
||||
SentrySDK.capture(event: event)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -573,12 +563,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
@MainActor
|
||||
private func restoreStatusesFromMarkerPosition() async -> Bool {
|
||||
do {
|
||||
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timelines: [.home]))
|
||||
guard let home = marker.home else {
|
||||
return false
|
||||
}
|
||||
async let status = try await mastodonController.run(Client.getStatus(id: home.lastReadID)).0
|
||||
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: home.lastReadID, count: Self.pageSize))).0
|
||||
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home))
|
||||
async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0
|
||||
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0
|
||||
|
||||
let allStatuses = try await [status] + olderStatuses
|
||||
await mastodonController.persistentContainer.addAll(statuses: allStatuses)
|
||||
|
@ -590,21 +577,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
await apply(snapshot, animatingDifferences: false)
|
||||
collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top)
|
||||
|
||||
stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(home.lastReadID)")
|
||||
stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(marker.lastReadID)")
|
||||
|
||||
return true
|
||||
} catch {
|
||||
stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))")
|
||||
|
||||
#if canImport(Sentry)
|
||||
if let error = error as? Client.Error,
|
||||
case .networkError(_) = error.type {
|
||||
return false
|
||||
}
|
||||
let event = Event(error: error)
|
||||
event.message = SentryMessage(formatted: "Failed to load from timeline marker: \(String(describing: error))")
|
||||
SentrySDK.capture(event: event)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
@ -1016,6 +993,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
|
||||
// this is copied from the TimelineLikeCollectionViewController implementation because it needs to be overridable by ListTimelineViewController
|
||||
func handleReplaceAllItems(_ timelineItems: [String]) async {
|
||||
var snapshot = dataSource.snapshot()
|
||||
if snapshot.sectionIdentifiers.contains(.entries) {
|
||||
snapshot.deleteSections([.entries])
|
||||
}
|
||||
snapshot.appendSections([.entries])
|
||||
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
||||
await apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TimelineViewController {
|
||||
|
|
|
@ -258,6 +258,12 @@ class SplitNavigationController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
extension SplitNavigationController: TabBarScrollableViewController {
|
||||
func tabBarScrollToTop() {
|
||||
(viewControllers.last as? TabBarScrollableViewController)?.tabBarScrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
extension SplitNavigationController: StatusBarTappableViewController {
|
||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||
let vcs = viewControllers
|
||||
|
|
|
@ -30,6 +30,8 @@ class AttachmentView: GIFImageView {
|
|||
var attachment: Attachment!
|
||||
var index: Int!
|
||||
|
||||
private var currentBadges: Badges = []
|
||||
|
||||
private var loadAttachmentTask: Task<Void, Never>?
|
||||
private var source: Source?
|
||||
var attachmentImage: UIImage? {
|
||||
|
@ -103,8 +105,9 @@ class AttachmentView: GIFImageView {
|
|||
}
|
||||
}
|
||||
|
||||
if getBadges().isEmpty != Preferences.shared.showAttachmentBadges {
|
||||
createBadgesView(getBadges())
|
||||
let newBadges = getBadges()
|
||||
if currentBadges != newBadges {
|
||||
createBadgesView(newBadges)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -175,8 +178,13 @@ class AttachmentView: GIFImageView {
|
|||
return []
|
||||
}
|
||||
var badges: Badges = []
|
||||
if attachment.description?.isEmpty == false {
|
||||
let hasDescription = attachment.description?.isEmpty == false
|
||||
if hasDescription,
|
||||
!Preferences.shared.attachmentAltBadgeInverted {
|
||||
badges.formUnion(.alt)
|
||||
} else if !hasDescription,
|
||||
Preferences.shared.attachmentAltBadgeInverted {
|
||||
badges.formUnion(.noAlt)
|
||||
}
|
||||
if attachment.kind == .gifv || attachment.url.pathExtension == "gif" {
|
||||
badges.formUnion(.gif)
|
||||
|
@ -354,6 +362,8 @@ class AttachmentView: GIFImageView {
|
|||
}
|
||||
|
||||
private func createBadgesView(_ badges: Badges) {
|
||||
currentBadges = badges
|
||||
|
||||
guard !badges.isEmpty else {
|
||||
badgeContainer?.removeFromSuperview()
|
||||
badgeContainer = nil
|
||||
|
@ -393,6 +403,9 @@ class AttachmentView: GIFImageView {
|
|||
if badges.contains(.alt) {
|
||||
makeBadgeView(text: "ALT")
|
||||
}
|
||||
if badges.contains(.noAlt) {
|
||||
makeBadgeView(text: "No ALT")
|
||||
}
|
||||
|
||||
let first = stack.arrangedSubviews.first!
|
||||
first.layer.masksToBounds = true
|
||||
|
@ -445,6 +458,7 @@ fileprivate extension AttachmentView {
|
|||
struct Badges: OptionSet {
|
||||
static let gif = Badges(rawValue: 1 << 0)
|
||||
static let alt = Badges(rawValue: 1 << 1)
|
||||
static let noAlt = Badges(rawValue: 1 << 2)
|
||||
|
||||
let rawValue: Int
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ class GifvController {
|
|||
self.isGrayscale = Preferences.shared.grayscaleImages
|
||||
|
||||
player.isMuted = true
|
||||
player.preventsDisplaySleepDuringVideoPlayback = false
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
// https://help.apple.com/xcode/#/dev745c5c974
|
||||
|
||||
MARKETING_VERSION = 2024.1
|
||||
CURRENT_PROJECT_VERSION = 115
|
||||
CURRENT_PROJECT_VERSION = 116
|
||||
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
||||
|
||||
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
||||
|
|
Loading…
Reference in New Issue