Compare commits

..

17 Commits

Author SHA1 Message Date
Shadowfacts e522e30ce5 Bump build number and update changelog 2024-04-01 21:39:43 -04:00
Shadowfacts c73784aa81 Mark notifications on Mastodon web frontend as read once displayed
Fixes #357
2024-04-01 19:51:57 -04:00
Shadowfacts 7affa09e5e Remove timeline marker Sentry reporting
I'm 99% sure these timeouts are all due to bad network conditions
2024-04-01 19:43:14 -04:00
Shadowfacts 7435d02f6e Fiddle with how the timeline markers API is organized 2024-04-01 19:22:55 -04:00
Shadowfacts 2467297f04 Add preference for inverted alt text badge
Closes #423
2024-04-01 18:47:19 -04:00
Shadowfacts cf317e15e9 Designed for iPad: Possible fix for gallery content being positioned/sized incorrectly
See #446
2024-04-01 12:40:30 -04:00
Shadowfacts bcae60316b Fix changing list reply policy not reloading list timeline 2024-04-01 11:04:40 -04:00
Shadowfacts 1a2fa10708 Improve edit list account removal animation 2024-04-01 11:02:33 -04:00
Shadowfacts f79c2feea6 Fix edit list screen not updating after adding account 2024-04-01 11:02:15 -04:00
Shadowfacts 7ec87d7853 Add no content message to list timelines
Closes #215

Also fix interactive dismissal of edit screen not reloading list
2024-04-01 10:58:42 -04:00
Shadowfacts f5704e561b Support tapping selected sidebar item to scroll to top 2024-04-01 10:35:54 -04:00
Shadowfacts d6faf3a37b Fix fast account switching view not respecting safe area 2024-04-01 10:28:40 -04:00
Shadowfacts b0a6952643 Fix trending hashtags screen not clearing selection 2024-04-01 09:47:05 -04:00
Shadowfacts 06b58cfb9c Fix notifications screen not responding to tab bar/status bar scroll to top 2024-04-01 09:45:22 -04:00
Shadowfacts afcec24f86 Fix reference cycles in gallery 2024-03-31 23:29:28 -04:00
Shadowfacts 3f90a0df04 Fix gifvs preventing sleep 2024-03-31 23:20:55 -04:00
Shadowfacts 395ce6523d Fix follows account list using wrong separator insets 2024-03-31 23:14:54 -04:00
28 changed files with 332 additions and 78 deletions

View File

@ -1,5 +1,23 @@
# Changelog # 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) ## 2024.1 (115)
Features/Improvements: Features/Improvements:
- Rewrite attachment gallery - Rewrite attachment gallery

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
@MainActor @MainActor
public protocol GalleryContentViewControllerContainer { public protocol GalleryContentViewControllerContainer: AnyObject {
var galleryControlsVisible: Bool { get } var galleryControlsVisible: Bool { get }
func setGalleryContentLoading(_ loading: Bool) func setGalleryContentLoading(_ loading: Bool)

View File

@ -10,7 +10,7 @@ import UIKit
@MainActor @MainActor
class GalleryDismissInteraction: NSObject { class GalleryDismissInteraction: NSObject {
private let viewController: GalleryViewController private unowned let viewController: GalleryViewController
private var content: GalleryContentViewController? private var content: GalleryContentViewController?
private var origContentFrameInGallery: CGRect? private var origContentFrameInGallery: CGRect?

View File

@ -43,6 +43,7 @@ class GalleryItemViewController: UIViewController {
private(set) var controlsVisible: Bool = true private(set) var controlsVisible: Bool = true
private(set) var scrollAndZoomEnabled = true private(set) var scrollAndZoomEnabled = true
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
override var prefersHomeIndicatorAutoHidden: Bool { override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible return !controlsVisible
} }
@ -219,6 +220,12 @@ class GalleryItemViewController: UIViewController {
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.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() centerContent()
} }
@ -303,6 +310,8 @@ class GalleryItemViewController: UIViewController {
} }
func updateZoomScale(resetZoom: Bool) { func updateZoomScale(resetZoom: Bool) {
scrollView.contentSize = content.contentSize
guard scrollAndZoomEnabled else { guard scrollAndZoomEnabled else {
scrollView.maximumZoomScale = 1 scrollView.maximumZoomScale = 1
scrollView.minimumZoomScale = 1 scrollView.minimumZoomScale = 1

View File

@ -7,26 +7,53 @@
import Foundation import Foundation
public struct TimelineMarkers: Decodable, Sendable { public struct TimelineMarkers {
public let home: Marker? private init() {}
public let notifications: Marker?
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> { public static func request<T: TimelineMarkerType>(timeline: T) -> Request<TimelineMarker<T.Payload>> {
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue)) Request(method: .get, path: "/api/v1/markers", queryParameters: ["timeline[]" => T.name])
} }
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> { public static func update<T: TimelineMarkerType>(timeline: T, lastReadID: String) -> Request<Empty> {
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([ Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
"\(timeline.rawValue)[last_read_id]" => lastReadID, "\(T.name)[last_read_id]" => lastReadID
])) ]))
} }
}
public enum Timeline: String { public struct TimelineMarker<Payload: TimelineMarkerTypePayload>: Decodable, Sendable {
case home let payload: Payload
case notifications
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 lastReadID: String
public let version: Int public let version: Int
public let updatedAt: Date public let updatedAt: Date
@ -36,5 +63,27 @@ public struct TimelineMarkers: Decodable, Sendable {
case version case version
case updatedAt = "updated_at" 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" }
} }

View File

@ -87,6 +87,7 @@ public final class Preferences: Codable, ObservableObject {
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs) self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? 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.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) 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(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline) try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges) try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges)
try container.encode(attachmentAltBadgeInverted, forKey: .attachmentAltBadgeInverted)
try container.encode(openLinksInApps, forKey: .openLinksInApps) try container.encode(openLinksInApps, forKey: .openLinksInApps)
try container.encode(useInAppSafari, forKey: .useInAppSafari) try container.encode(useInAppSafari, forKey: .useInAppSafari)
@ -211,6 +213,7 @@ public final class Preferences: Codable, ObservableObject {
@Published public var automaticallyPlayGifs = true @Published public var automaticallyPlayGifs = true
@Published public var showUncroppedMediaInline = true @Published public var showUncroppedMediaInline = true
@Published public var showAttachmentBadges = true @Published public var showAttachmentBadges = true
@Published public var attachmentAltBadgeInverted = false
// MARK: Behavior // MARK: Behavior
@Published public var openLinksInApps = true @Published public var openLinksInApps = true
@ -274,6 +277,7 @@ public final class Preferences: Codable, ObservableObject {
case automaticallyPlayGifs case automaticallyPlayGifs
case showUncroppedMediaInline case showUncroppedMediaInline
case showAttachmentBadges case showAttachmentBadges
case attachmentAltBadgeInverted
case openLinksInApps case openLinksInApps
case useInAppSafari case useInAppSafari

View File

@ -52,6 +52,12 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} }
if config.topSeparatorInsets != .zero {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
if config.bottomSeparatorInsets != .zero {
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
return config return config
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in

View File

@ -33,6 +33,8 @@ class AccountListViewController: UIViewController, CollectionViewController {
override func loadView() { override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .grouped) var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground config.backgroundColor = .appGroupedBackground
config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
section.readableContentInset(in: environment) section.readableContentInset(in: environment)

View File

@ -11,11 +11,11 @@ import Pachyderm
import WebURLFoundationExtras import WebURLFoundationExtras
import Combine import Combine
class TrendingHashtagsViewController: UIViewController { class TrendingHashtagsViewController: UIViewController, CollectionViewController {
private let mastodonController: MastodonController private let mastodonController: MastodonController
private var collectionView: UICollectionView! private(set) var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var state = State.unloaded private var state = State.unloaded
@ -84,6 +84,8 @@ class TrendingHashtagsViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
Task { Task {
await loadInitial() await loadInitial()
} }

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <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="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
@ -32,7 +32,6 @@
</subviews> </subviews>
<constraints> <constraints>
<constraint firstItem="lYU-Bb-3Wi" firstAttribute="top" secondItem="1Gd-Da-Vab" secondAttribute="topMargin" placeholder="YES" id="KQs-d5-U3f"/> <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 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"/> <constraint firstItem="lYU-Bb-3Wi" firstAttribute="leading" secondItem="1Gd-Da-Vab" secondAttribute="leading" constant="8" id="sae-ga-MGE"/>
</constraints> </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="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 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 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> </constraints>
<point key="canvasLocation" x="140.57971014492756" y="144.64285714285714"/> <point key="canvasLocation" x="140.57971014492756" y="144.64285714285714"/>
</view> </view>

View File

@ -70,7 +70,7 @@ class FallbackGalleryNavigationController: UINavigationController, GalleryConten
// MARK: GalleryContentViewController // MARK: GalleryContentViewController
var container: (any GalleryVC.GalleryContentViewControllerContainer)? weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
var contentSize: CGSize { var contentSize: CGSize {
.zero .zero

View File

@ -59,7 +59,7 @@ class GifvGalleryContentViewController: UIViewController, GalleryContentViewCont
// MARK: GalleryContentViewController // MARK: GalleryContentViewController
var container: (any GalleryVC.GalleryContentViewControllerContainer)? weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
var contentSize: CGSize { var contentSize: CGSize {
controller.item.presentationSize controller.item.presentationSize

View File

@ -114,7 +114,7 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
// MARK: GalleryContentViewController // MARK: GalleryContentViewController
var container: (any GalleryVC.GalleryContentViewControllerContainer)? weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
var contentSize: CGSize { var contentSize: CGSize {
image.size image.size

View File

@ -13,7 +13,7 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
private let provider: () async -> (any GalleryContentViewController)? private let provider: () async -> (any GalleryContentViewController)?
private var wrapped: (any GalleryContentViewController)! private var wrapped: (any GalleryContentViewController)!
var container: GalleryContentViewControllerContainer? weak var container: GalleryContentViewControllerContainer?
var contentSize: CGSize { var contentSize: CGSize {
wrapped?.contentSize ?? .zero wrapped?.contentSize ?? .zero

View File

@ -132,7 +132,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
// MARK: GalleryContentViewController // MARK: GalleryContentViewController
var container: (any GalleryVC.GalleryContentViewControllerContainer)? weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
var contentSize: CGSize { var contentSize: CGSize {
item.presentationSize item.presentationSize

View File

@ -17,7 +17,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
private var state = State.unloaded private var state = State.unloaded
private(set) var changedAccounts = false private(set) var shouldReloadListTimeline = false
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var collectionView: UICollectionView! { view as? UICollectionView } var collectionView: UICollectionView! { view as? UICollectionView }
@ -186,7 +186,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
@MainActor @MainActor
private func loadAccounts() async { private func loadAccounts() async {
guard state == .unloaded else { return } guard state == .unloaded || state == .loaded else { return }
state = .loading state = .loading
@ -278,7 +278,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
} }
private func addAccount(id: String) async { private func addAccount(id: String) async {
changedAccounts = true shouldReloadListTimeline = true
do { do {
let req = List.add(list.id, accounts: [id]) let req = List.add(list.id, accounts: [id])
_ = try await mastodonController.run(req) _ = try await mastodonController.run(req)
@ -294,11 +294,19 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
} }
private func removeAccount(id: String) async { private func removeAccount(id: String) async {
changedAccounts = true shouldReloadListTimeline = true
do { do {
let request = List.remove(list.id, accounts: [id]) let request = List.remove(list.id, accounts: [id])
_ = try await mastodonController.run(request) _ = 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 { } catch {
let config = ToastConfiguration(from: error, with: "Error Removing Account", in: self) { [unowned self] toast in let config = ToastConfiguration(from: error, with: "Error Removing Account", in: self) { [unowned self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
@ -309,6 +317,7 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
} }
private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) { private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) {
shouldReloadListTimeline = true
Task { Task {
let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
await service.run(replyPolicy: replyPolicy) await service.run(replyPolicy: replyPolicy)

View File

@ -16,6 +16,8 @@ class ListTimelineViewController: TimelineViewController {
var presentEditOnAppear = false var presentEditOnAppear = false
private var noContentView: UIStackView!
private var listRenamedCancellable: AnyCancellable? private var listRenamedCancellable: AnyCancellable?
init(for list: List, mastodonController: MastodonController) { 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() { private func listChanged() {
title = list.title title = list.title
} }
@ -61,10 +96,23 @@ class ListTimelineViewController: TimelineViewController {
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController) let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
editListAccountsController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) editListAccountsController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
let navController = UINavigationController(rootViewController: editListAccountsController) let navController = UINavigationController(rootViewController: editListAccountsController)
navController.sheetPresentationController?.delegate = self
present(navController, animated: animated) 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() { @objc func editListButtonPressed() {
presentEdit(animated: true) presentEdit(animated: true)
@ -75,12 +123,28 @@ class ListTimelineViewController: TimelineViewController {
dismiss(animated: true) dismiss(animated: true)
if presented?.changedAccounts == true { if let presented {
Task { self.reloadIfNecessary(editViewController: presented)
applyInitialSnapshot()
await controller.loadInitial()
}
} }
} }
// 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)
}
} }

View File

@ -15,6 +15,7 @@ protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
} }
class MainSidebarViewController: UIViewController { class MainSidebarViewController: UIViewController {
@ -451,7 +452,9 @@ extension MainSidebarViewController: UICollectionViewDelegate {
return return
} }
itemLastSelectedTimestamps[item] = Date() 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) { if let previous = previouslySelectedItem, let indexPath = dataSource.indexPath(for: previous) {
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically) collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
} }

View File

@ -478,6 +478,10 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
} }
secondaryNavController.viewControllers = [viewController] secondaryNavController.viewControllers = [viewController]
} }
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item) {
(secondaryNavController as? TabBarScrollableViewController)?.tabBarScrollToTop()
}
} }
fileprivate extension MainSidebarViewController.Item { fileprivate extension MainSidebarViewController.Item {

View File

@ -241,6 +241,7 @@ extension MainTabBarViewController {
#if !os(visionOS) #if !os(visionOS)
extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate { extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) { func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) {
fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(fastAccountSwitcher.view) view.addSubview(fastAccountSwitcher.view)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
fastAccountSwitcher.accountsStack.bottomAnchor.constraint(equalTo: fastAccountSwitcher.view.bottomAnchor), fastAccountSwitcher.accountsStack.bottomAnchor.constraint(equalTo: fastAccountSwitcher.view.bottomAnchor),
@ -249,6 +250,10 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor), fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: tabBar.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),
]) ])
} }

View File

@ -12,6 +12,9 @@ import Combine
#if canImport(Sentry) #if canImport(Sentry)
import Sentry import Sentry
#endif #endif
import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationsCVC")
class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController { class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
@ -32,6 +35,9 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
private var newer: RequestRange? private var newer: RequestRange?
private var older: RequestRange? private var older: RequestRange?
var updatesNotificationsMarker: Bool = false
private var newestDisplayedNotification: Item?
init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) { init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) {
self.allowedTypes = allowedTypes self.allowedTypes = allowedTypes
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -205,6 +211,12 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
} }
} }
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
updateNotificationsMarkerIfNecessary()
}
@objc func refresh() { @objc func refresh() {
Task { @MainActor in Task { @MainActor in
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
@ -311,6 +323,23 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
await apply(snapshot, animatingDifferences: true) 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 { extension NotificationsCollectionViewController {
@ -545,6 +574,21 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) else { guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) else {
return 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) let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
if indexPath.row == itemsInSection - 1 { if indexPath.row == itemsInSection - 1 {
Task { 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()
}
}

View File

@ -24,6 +24,7 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
let vc = NotificationsCollectionViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController) let vc = NotificationsCollectionViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
vc.title = page.title vc.title = page.title
vc.userActivity = page.userActivity(accountID: mastodonController.accountInfo!.id) vc.userActivity = page.userActivity(accountID: mastodonController.accountInfo!.id)
vc.updatesNotificationsMarker = page == .all
return vc return vc
} }

View File

@ -44,8 +44,13 @@ struct MediaPrefsView: View {
} }
Toggle(isOn: $preferences.showAttachmentBadges) { 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() .appGroupedListRowBackground()
} }

View File

@ -387,16 +387,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
_ = try await mastodonController.run(req) _ = try await mastodonController.run(req)
} catch { } catch {
stateRestorationLogger.error("TimelineViewController: failed to update timeline marker: \(String(describing: error))") 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 @MainActor
private func restoreStatusesFromMarkerPosition() async -> Bool { private func restoreStatusesFromMarkerPosition() async -> Bool {
do { do {
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timelines: [.home])) let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home))
guard let home = marker.home else { async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0
return false async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0
}
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 allStatuses = try await [status] + olderStatuses let allStatuses = try await [status] + olderStatuses
await mastodonController.persistentContainer.addAll(statuses: allStatuses) await mastodonController.persistentContainer.addAll(statuses: allStatuses)
@ -590,21 +577,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
await apply(snapshot, animatingDifferences: false) await apply(snapshot, animatingDifferences: false)
collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top) 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 return true
} catch { } catch {
stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))") 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 return false
} }
} }
@ -1016,6 +993,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
self.showToast(configuration: config, animated: true) 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 { extension TimelineViewController {

View File

@ -258,6 +258,12 @@ class SplitNavigationController: UIViewController {
} }
} }
extension SplitNavigationController: TabBarScrollableViewController {
func tabBarScrollToTop() {
(viewControllers.last as? TabBarScrollableViewController)?.tabBarScrollToTop()
}
}
extension SplitNavigationController: StatusBarTappableViewController { extension SplitNavigationController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
let vcs = viewControllers let vcs = viewControllers

View File

@ -30,6 +30,8 @@ class AttachmentView: GIFImageView {
var attachment: Attachment! var attachment: Attachment!
var index: Int! var index: Int!
private var currentBadges: Badges = []
private var loadAttachmentTask: Task<Void, Never>? private var loadAttachmentTask: Task<Void, Never>?
private var source: Source? private var source: Source?
var attachmentImage: UIImage? { var attachmentImage: UIImage? {
@ -103,8 +105,9 @@ class AttachmentView: GIFImageView {
} }
} }
if getBadges().isEmpty != Preferences.shared.showAttachmentBadges { let newBadges = getBadges()
createBadgesView(getBadges()) if currentBadges != newBadges {
createBadgesView(newBadges)
} }
} }
@ -175,8 +178,13 @@ class AttachmentView: GIFImageView {
return [] return []
} }
var badges: Badges = [] var badges: Badges = []
if attachment.description?.isEmpty == false { let hasDescription = attachment.description?.isEmpty == false
if hasDescription,
!Preferences.shared.attachmentAltBadgeInverted {
badges.formUnion(.alt) badges.formUnion(.alt)
} else if !hasDescription,
Preferences.shared.attachmentAltBadgeInverted {
badges.formUnion(.noAlt)
} }
if attachment.kind == .gifv || attachment.url.pathExtension == "gif" { if attachment.kind == .gifv || attachment.url.pathExtension == "gif" {
badges.formUnion(.gif) badges.formUnion(.gif)
@ -354,6 +362,8 @@ class AttachmentView: GIFImageView {
} }
private func createBadgesView(_ badges: Badges) { private func createBadgesView(_ badges: Badges) {
currentBadges = badges
guard !badges.isEmpty else { guard !badges.isEmpty else {
badgeContainer?.removeFromSuperview() badgeContainer?.removeFromSuperview()
badgeContainer = nil badgeContainer = nil
@ -393,6 +403,9 @@ class AttachmentView: GIFImageView {
if badges.contains(.alt) { if badges.contains(.alt) {
makeBadgeView(text: "ALT") makeBadgeView(text: "ALT")
} }
if badges.contains(.noAlt) {
makeBadgeView(text: "No ALT")
}
let first = stack.arrangedSubviews.first! let first = stack.arrangedSubviews.first!
first.layer.masksToBounds = true first.layer.masksToBounds = true
@ -445,6 +458,7 @@ fileprivate extension AttachmentView {
struct Badges: OptionSet { struct Badges: OptionSet {
static let gif = Badges(rawValue: 1 << 0) static let gif = Badges(rawValue: 1 << 0)
static let alt = Badges(rawValue: 1 << 1) static let alt = Badges(rawValue: 1 << 1)
static let noAlt = Badges(rawValue: 1 << 2)
let rawValue: Int let rawValue: Int
} }

View File

@ -29,6 +29,7 @@ class GifvController {
self.isGrayscale = Preferences.shared.grayscaleImages self.isGrayscale = Preferences.shared.grayscaleImages
player.isMuted = true player.isMuted = true
player.preventsDisplaySleepDuringVideoPlayback = false
NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item) NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974 // https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.1 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 = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev