Compare commits

...

7 Commits

17 changed files with 143 additions and 137 deletions

View File

@ -1,5 +1,15 @@
# Changelog
## 2024.1 (117)
Features/Improvements:
- Add See Results button to polls
Bugfixes:
- Fix race condition when presenting gallery for 4th of more than 4 attachments
- Fix gallery interactive dismissal not working for 4th or later attachments on posts with more than 4 attachments
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
- macOS: Fix gallery being positioned incorrectly when Reduce Motion is on
## 2024.1 (116)
Features/Improvements:
- Display message on empty list timelines

View File

@ -52,6 +52,9 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
appliedSourceToDestTransform = false
}
to.view.frame = container.bounds
from.view.frame = container.bounds
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.layer.masksToBounds = true
@ -112,6 +115,8 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
return
}
toVC.view.frame = transitionContext.containerView.bounds
fromVC.view.frame = transitionContext.containerView.bounds
transitionContext.containerView.addSubview(toVC.view)
transitionContext.containerView.addSubview(fromVC.view)

View File

@ -109,8 +109,6 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
itemViewController.addContent()
transitionContext.completeTransition(true)
to.presentationAnimationCompleted()
}
animator.startAnimation()
@ -121,8 +119,9 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
return
}
transitionContext.containerView.addSubview(to.view)
to.view.alpha = 0
to.view.frame = transitionContext.containerView.bounds
transitionContext.containerView.addSubview(to.view)
let duration = transitionDuration(using: transitionContext)
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
@ -131,8 +130,6 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
}
animator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
to.presentationAnimationCompleted()
}
animator.startAnimation()
}

View File

@ -68,6 +68,17 @@ public class GalleryViewController: UIPageViewController {
setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if animated {
// Wait until the transition is no longer in-progress, otherwise things will just get deferred again.
DispatchQueue.main.async {
self.presentationAnimationCompleted()
}
}
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)

View File

@ -255,7 +255,6 @@
D6B81F442560390300F6E31D /* MenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F432560390300F6E31D /* MenuController.swift */; };
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */; };
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366A281EE77E00237D0E /* PollVoteButton.swift */; };
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; };
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366E2828452F00237D0E /* SavedHashtag.swift */; };
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */; };
@ -658,7 +657,6 @@
D6B81F432560390300F6E31D /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = "<group>"; };
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarMyProfileCollectionViewCell.swift; sourceTree = "<group>"; };
D6B9366A281EE77E00237D0E /* PollVoteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollVoteButton.swift; sourceTree = "<group>"; };
D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.swift; sourceTree = "<group>"; };
D6B9366E2828452F00237D0E /* SavedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtag.swift; sourceTree = "<group>"; };
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Helpers.swift"; sourceTree = "<group>"; };
@ -898,7 +896,6 @@
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */,
D623A5402635FB3C0095BD04 /* PollOptionView.swift */,
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */,
D6B9366A281EE77E00237D0E /* PollVoteButton.swift */,
);
path = Poll;
sourceTree = "<group>";
@ -2201,7 +2198,6 @@
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
D68A76DA29511CA6001DA1B3 /* AccountPreferences.swift in Sources */,
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,

View File

@ -15,10 +15,12 @@ import AVFoundation
class StatusAttachmentsGalleryDataSource: GalleryDataSource {
let attachments: [Attachment]
let sourceViews: NSHashTable<AttachmentView>
weak var moreView: UIView?
init(attachments: [Attachment], sourceViews: NSHashTable<AttachmentView>) {
init(attachments: [Attachment], sourceViews: NSHashTable<AttachmentView>, moreView: UIView?) {
self.attachments = attachments
self.sourceViews = sourceViews
self.moreView = moreView
}
func galleryItemsCount() -> Int {
@ -39,6 +41,21 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
// TODO: if automatically play gifs is off, this will start the source view playing too
gifController: view.gifController
)
} else if let entry = ImageCache.attachments.get(attachment.url, loadOriginal: true) {
let gifController: GIFController? =
if attachment.url.pathExtension == "gif",
let data = entry.data {
GIFController(gifData: data)
} else {
nil
}
return ImageGalleryContentViewController(
url: attachment.url,
caption: attachment.description,
originalData: entry.data,
image: entry.image,
gifController: gifController
)
} else {
return LoadingGalleryContentViewController {
let (data, image) = await ImageCache.attachments.get(attachment.url, loadOriginal: true)
@ -92,8 +109,12 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
}
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? {
let attachment = attachments[index]
return attachmentView(for: attachment)
if attachments.count > 4 && index >= 3 {
return moreView
} else {
let attachment = attachments[index]
return attachmentView(for: attachment)
}
}
func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]? {

View File

@ -135,9 +135,11 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
updateTimestamp()
let people = group.notifications.compactMap {
mastodonController.persistentContainer.account(for: $0.account.id)
}
let people = group.notifications
.uniques(by: \.account.id)
.compactMap {
mastodonController.persistentContainer.account(for: $0.account.id)
}
let visibleAvatars = Array(people.lazy.compactMap(\.avatar).prefix(10))
for (index, avatarURL) in visibleAvatars.enumerated() {

View File

@ -109,7 +109,11 @@ class FollowNotificationGroupCollectionViewCell: UICollectionViewListCell {
}
self.group = group
let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) }
let people = group.notifications
.uniques(by: \.account.id)
.compactMap {
mastodonController.persistentContainer.account(for: $0.account.id)
}
actionLabel.setEmojis(pairs: people.map {
($0.displayOrUserName, $0.emojis)

View File

@ -627,11 +627,11 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
case .favourite, .reblog:
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id)
let accountIDs = group.notifications.map(\.account.id).uniques()
let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
show(vc)
case .follow:
let accountIDs = group.notifications.map(\.account.id)
let accountIDs = group.notifications.map(\.account.id).uniques()
switch accountIDs.count {
case 0:
collectionView.deselectItem(at: indexPath, animated: true)
@ -670,11 +670,11 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(previewProvider: {
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id)
let accountIDs = group.notifications.map(\.account.id).uniques()
return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
})
case .follow:
let accountIDs = group.notifications.map(\.account.id)
let accountIDs = group.notifications.map(\.account.id).uniques()
return UIContextMenuConfiguration {
if accountIDs.count == 1 {
return ProfileViewController(accountID: accountIDs.first!, mastodonController: self.mastodonController)

View File

@ -238,7 +238,8 @@ extension StatusEditCollectionViewCell: AttachmentViewDelegate {
func attachmentViewGallery(startingAt index: Int) -> UIViewController? {
let attachments = attachmentsView.attachments!
let sourceViews = attachmentsView.attachmentViews.copy() as! NSHashTable<AttachmentView>
return GalleryVC.GalleryViewController(dataSource: StatusAttachmentsGalleryDataSource(attachments: attachments, sourceViews: sourceViews), initialItemIndex: index)
let dataSource = StatusAttachmentsGalleryDataSource(attachments: attachments, sourceViews: sourceViews, moreView: attachmentsView.moreView)
return GalleryVC.GalleryViewController(dataSource: dataSource, initialItemIndex: index)
}
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {

View File

@ -18,7 +18,7 @@ class AttachmentsContainerView: UIView {
let attachmentViews: NSHashTable<AttachmentView> = .weakObjects()
let attachmentStacks: NSHashTable<UIStackView> = .weakObjects()
var moreView: UIView?
private(set) var moreView: UIView?
private var aspectRatioConstraint: NSLayoutConstraint?
private(set) var aspectRatio: CGFloat = 16/9 {
didSet {

View File

@ -75,7 +75,7 @@ class PollOptionView: UIView {
fatalError("init(coder:) has not been implemented")
}
func updateUI(poll: Poll, option: Poll.Option, ownVoted: Bool, mastodonController: MastodonController) {
func updateUI(poll: Poll, option: Poll.Option, ownVoted: Bool, showResults: Bool, mastodonController: MastodonController) {
let showCheckbox = poll.ownVotes?.isEmpty == false || !poll.effectiveExpired
if showCheckbox {
checkbox.isChecked = ownVoted
@ -100,7 +100,7 @@ class PollOptionView: UIView {
accessibilityLabel = option.title
if (poll.voted ?? false) || poll.effectiveExpired,
if showResults,
let optionVotes = option.votesCount {
let frac: CGFloat
if poll.multiple,

View File

@ -65,7 +65,7 @@ class PollOptionsView: UIControl {
fatalError("init(coder:) has not been implemented")
}
func updateUI(poll: Poll) {
func updateUI(poll: Poll, showResults: Bool) {
self.poll = poll
if poll.options.count > options.count {
@ -81,7 +81,7 @@ class PollOptionsView: UIControl {
}
for (index, (view, opt)) in zip(options, poll.options).enumerated() {
view.updateUI(poll: poll, option: opt, ownVoted: poll.ownVotes?.contains(index) ?? false, mastodonController: mastodonController)
view.updateUI(poll: poll, option: opt, ownVoted: poll.ownVotes?.contains(index) ?? false, showResults: showResults, mastodonController: mastodonController)
view.checkboxIfInitialized?.readOnly = !isEnabled
}
}

View File

@ -1,81 +0,0 @@
//
// PollVoteButton.swift
// Tusker
//
// Created by Shadowfacts on 5/1/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
/// Wraps a UILabel and UIButton to allow setting disabled titles on Catalyst, where `setTitle(_:for:)` only works for the normal state.
class PollVoteButton: UIView {
var disabledTitle: String = "" {
didSet {
update()
}
}
var isEnabled = true {
didSet {
update()
}
}
private var button = UIButton(type: .system)
#if targetEnvironment(macCatalyst)
private var label = UILabel()
#endif
override init(frame: CGRect) {
super.init(frame: frame)
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitleColor(.secondaryLabel, for: .disabled)
button.contentHorizontalAlignment = .trailing
embedSubview(button)
#if targetEnvironment(macCatalyst)
label.textColor = .secondaryLabel
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .right
embedSubview(label)
#endif
update()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func addTarget(_ target: Any, action: Selector) {
button.addTarget(target, action: action, for: .touchUpInside)
}
func setFont(_ font: UIFont) {
button.titleLabel!.font = font
#if targetEnvironment(macCatalyst)
label.font = font
#endif
}
private func update() {
button.isEnabled = isEnabled
if isEnabled {
#if targetEnvironment(macCatalyst)
label.isHidden = true
button.isHidden = false
#endif
button.setTitle("Vote", for: .normal)
} else {
#if targetEnvironment(macCatalyst)
label.text = disabledTitle
label.isHidden = false
button.isHidden = true
#else
button.setTitle(disabledTitle, for: .disabled)
#endif
}
}
}

View File

@ -15,7 +15,7 @@ class StatusPollView: UIView, StatusContentView {
let f = DateComponentsFormatter()
f.includesTimeRemainingPhrase = true
f.maximumUnitCount = 1
f.unitsStyle = .full
f.unitsStyle = .abbreviated
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
return f
}()
@ -25,9 +25,10 @@ class StatusPollView: UIView, StatusContentView {
private var statusID: String!
private(set) var poll: Poll?
private var showingResults = false
private var optionsView: PollOptionsView!
private var voteButton: PollVoteButton!
private var voteButton: UIButton!
private var infoLabel: UILabel!
private var canVote = true
@ -63,11 +64,11 @@ class StatusPollView: UIView, StatusContentView {
infoLabel.adjustsFontSizeToFitWidth = true
addSubview(infoLabel)
voteButton = PollVoteButton()
voteButton = UIButton(configuration: .plain())
voteButton.translatesAutoresizingMaskIntoConstraints = false
voteButton.addTarget(self, action: #selector(votePressed))
voteButton.setFont(infoLabel.font)
voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
voteButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
addSubview(voteButton)
NSLayoutConstraint.activate([
@ -76,20 +77,24 @@ class StatusPollView: UIView, StatusContentView {
optionsView.topAnchor.constraint(equalTo: topAnchor),
infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
infoLabel.topAnchor.constraint(equalTo: optionsView.bottomAnchor),
infoLabel.topAnchor.constraint(equalTo: optionsView.bottomAnchor, constant: 4),
infoLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
infoLabel.trailingAnchor.constraint(equalTo: voteButton.leadingAnchor, constant: -8),
voteButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
voteButton.trailingAnchor.constraint(equalTo: trailingAnchor),
voteButton.topAnchor.constraint(equalTo: optionsView.bottomAnchor),
voteButton.bottomAnchor.constraint(equalTo: bottomAnchor),
voteButton.topAnchor.constraint(equalTo: infoLabel.topAnchor),
voteButton.bottomAnchor.constraint(equalTo: infoLabel.bottomAnchor),
])
accessibilityElements = [optionsView!, infoLabel!, voteButton!]
}
func updateUI(status: StatusMO, poll: Poll?) {
if statusID != status.id {
showingResults = false
}
self.statusID = status.id
self.poll = poll
@ -104,19 +109,17 @@ class StatusPollView: UIView, StatusContentView {
optionsView.mastodonController = mastodonController
optionsView.isEnabled = canVote
optionsView.updateUI(poll: poll)
optionsView.updateUI(poll: poll, showResults: showingResults || !canVote)
var expired = false
let expiryText: String?
if let expiresAt = poll.expiresAt {
if expiresAt > Date() {
expiryText = StatusPollView.formatter.string(from: Date(), to: expiresAt)
} else {
expired = true
expiryText = nil
}
} else {
expiryText = "Does not expire"
expiryText = "No expiry"
}
let format = NSLocalizedString("poll votes count", comment: "poll total votes count")
@ -125,20 +128,7 @@ class StatusPollView: UIView, StatusContentView {
infoLabel.text! += ", \(expiryText)"
}
if expired {
voteButton.disabledTitle = "Expired"
} else if poll.voted ?? false {
if status.account.id == mastodonController.account?.id {
voteButton.isHidden = true
} else {
voteButton.disabledTitle = "Voted"
}
} else if poll.multiple {
voteButton.disabledTitle = "Select multiple"
} else {
voteButton.disabledTitle = "Select one"
}
voteButton.isEnabled = false
updateVoteButton(status: status, poll: poll)
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
@ -146,11 +136,54 @@ class StatusPollView: UIView, StatusContentView {
return optionsView.estimateHeight(effectiveWidth: effectiveWidth) + infoLabel.sizeThatFits(UIView.layoutFittingExpandedSize).height
}
private func updateVoteButton(status: StatusMO, poll: Poll) {
let buttonTitle: String
let buttonEnabled: Bool
if let expiresAt = poll.expiresAt,
expiresAt <= Date() {
buttonTitle = "Expired"
buttonEnabled = false
} else if poll.voted ?? false {
if status.account.id == mastodonController.account?.id {
voteButton.isHidden = true
return
} else {
buttonTitle = "Voted"
buttonEnabled = false
}
} else if optionsView.checkedOptionIndices.isEmpty {
buttonTitle = "See Results"
buttonEnabled = true
} else {
buttonTitle = "Vote"
buttonEnabled = true
}
var config = UIButton.Configuration.plain()
config.attributedTitle = AttributedString(buttonTitle)
config.attributedTitle!.font = infoLabel.font
config.contentInsets = .zero
// Necessary on Catalyst for some reason.
config.baseForegroundColor = .tintColor
voteButton.configuration = config
voteButton.isEnabled = buttonEnabled
}
private func checkedOptionsChanged() {
voteButton.isEnabled = optionsView.checkedOptionIndices.count > 0
if let status = mastodonController.persistentContainer.status(for: statusID),
let poll = status.poll {
updateVoteButton(status: status, poll: poll)
}
}
@objc private func votePressed() {
if !optionsView.checkedOptionIndices.isEmpty {
doVote()
} else {
showResults()
}
}
private func doVote() {
guard let statusID,
let poll else {
return
@ -158,7 +191,6 @@ class StatusPollView: UIView, StatusContentView {
optionsView.isEnabled = false
voteButton.isEnabled = false
voteButton.disabledTitle = "Voted"
#if !os(visionOS)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
@ -191,4 +223,11 @@ class StatusPollView: UIView, StatusContentView {
}
}
private func showResults() {
showingResults = true
if let status = mastodonController.persistentContainer.status(for: statusID) {
updateUI(status: status, poll: status.poll)
}
}
}

View File

@ -334,7 +334,8 @@ extension StatusCollectionViewCell {
return nil
}
let sourceViews = attachmentsView.attachmentViews.copy() as! NSHashTable<AttachmentView>
return GalleryVC.GalleryViewController(dataSource: StatusAttachmentsGalleryDataSource(attachments: status.attachments, sourceViews: sourceViews), initialItemIndex: index)
let dataSource = StatusAttachmentsGalleryDataSource(attachments: status.attachments, sourceViews: sourceViews, moreView: attachmentsView.moreView)
return GalleryVC.GalleryViewController(dataSource: dataSource, initialItemIndex: index)
}
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.1
CURRENT_PROJECT_VERSION = 116
CURRENT_PROJECT_VERSION = 117
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev