Compare commits

..

No commits in common. "203c1852d4d0bce087358626b1c5b2b8efd498f7" and "bf6dfab1218205d70cc311d6167197cd4cfba1f7" have entirely different histories.

7 changed files with 123 additions and 136 deletions

View File

@ -147,8 +147,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true) Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
} }
snapshot.appendItems(parentItems, toSection: .ancestors) snapshot.appendItems(parentItems, toSection: .ancestors)
// don't need to reconfigure main item, since when the refreshed copy was loaded snapshot.reconfigureItems([mainStatusItem])
// it would have triggered a reconfigure via the status observer
// convert sub-threads into items for section and add to snapshot // convert sub-threads into items for section and add to snapshot
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot) self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)

View File

@ -31,7 +31,7 @@ class StatusEditPollView: UIStackView, StatusContentPollView {
for option in poll?.options ?? [] { for option in poll?.options ?? [] {
// the edit poll doesn't actually include the multiple value // the edit poll doesn't actually include the multiple value
let icon = PollOptionCheckboxView() let icon = PollOptionCheckboxView(multiple: false)
icon.readOnly = false // this is a lie, but it's only used for stylistic changes icon.readOnly = false // this is a lie, but it's only used for stylistic changes
let label = EmojiLabel() let label = EmojiLabel()
label.text = option.title label.text = option.title

View File

@ -63,20 +63,19 @@ class TimelineLikeController<Item: Sendable> {
} }
let token = LoadAttemptToken() let token = LoadAttemptToken()
state = .loadingInitial(token, hasAddedLoadingIndicator: false) state = .loadingInitial(token, hasAddedLoadingIndicator: false)
await emit(event: .addLoadingIndicator) let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingInitial(token, hasAddedLoadingIndicator: true))
state = .loadingInitial(token, hasAddedLoadingIndicator: true)
do { do {
let items = try await delegate.loadInitial() let items = try await delegate.loadInitial()
guard case .loadingInitial(token, _) = state else { guard case .loadingInitial(token, _) = state else {
return return
} }
await loadingIndicator.end()
await emit(event: .replaceAllItems(items, token)) await emit(event: .replaceAllItems(items, token))
await emit(event: .removeLoadingIndicator)
state = .idle state = .idle
} catch is CancellationError { } catch is CancellationError {
return return
} catch { } catch {
await emit(event: .removeLoadingIndicator) await loadingIndicator.end()
await emit(event: .loadAllError(error, token)) await emit(event: .loadAllError(error, token))
state = .notLoadedInitial state = .notLoadedInitial
} }
@ -89,10 +88,9 @@ class TimelineLikeController<Item: Sendable> {
} }
let token = LoadAttemptToken() let token = LoadAttemptToken()
state = .restoringInitial(token, hasAddedLoadingIndicator: false) state = .restoringInitial(token, hasAddedLoadingIndicator: false)
await emit(event: .addLoadingIndicator) let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .restoringInitial(token, hasAddedLoadingIndicator: true))
state = .restoringInitial(token, hasAddedLoadingIndicator: true)
await doRestore() await doRestore()
await emit(event: .removeLoadingIndicator) await loadingIndicator.end()
state = .idle state = .idle
} }
@ -130,20 +128,19 @@ class TimelineLikeController<Item: Sendable> {
return return
} }
state = .loadingOlder(token, hasAddedLoadingIndicator: false) state = .loadingOlder(token, hasAddedLoadingIndicator: false)
await emit(event: .addLoadingIndicator) let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingOlder(token, hasAddedLoadingIndicator: true))
state = .loadingOlder(token, hasAddedLoadingIndicator: true)
do { do {
let items = try await delegate.loadOlder() let items = try await delegate.loadOlder()
guard case .loadingOlder(token, _) = state else { guard case .loadingOlder(token, _) = state else {
return return
} }
await loadingIndicator.end()
await emit(event: .appendItems(items, token)) await emit(event: .appendItems(items, token))
await emit(event: .removeLoadingIndicator)
state = .idle state = .idle
} catch is CancellationError { } catch is CancellationError {
return return
} catch { } catch {
await emit(event: .removeLoadingIndicator) await loadingIndicator.end()
await emit(event: .loadOlderError(error, token)) await emit(event: .loadOlderError(error, token))
state = .idle state = .idle
} }
@ -352,6 +349,34 @@ class TimelineLikeController<Item: Sendable> {
} }
} }
@MainActor
class DeferredLoadingIndicator {
private let owner: TimelineLikeController<Item>
private let addedIndicatorState: State
private let task: Task<Void, Error>
init(owner: TimelineLikeController<Item>, state: State, addedIndicatorState: State) {
self.owner = owner
self.addedIndicatorState = addedIndicatorState
self.task = Task { @MainActor in
try await Task.sleep(nanoseconds: 150 * NSEC_PER_MSEC)
guard state == owner.state else {
return
}
await owner.emit(event: .addLoadingIndicator)
owner.transition(to: addedIndicatorState)
}
}
func end() async {
if owner.state == addedIndicatorState {
await owner.emit(event: .removeLoadingIndicator)
} else {
task.cancel()
}
}
}
} }
enum TimelineGapDirection { enum TimelineGapDirection {

View File

@ -10,8 +10,6 @@ import UIKit
class PollOptionCheckboxView: UIView { class PollOptionCheckboxView: UIView {
private static let size: CGFloat = 20
var isChecked: Bool = false { var isChecked: Bool = false {
didSet { didSet {
updateStyle() updateStyle()
@ -27,19 +25,16 @@ class PollOptionCheckboxView: UIView {
updateStyle() updateStyle()
} }
} }
var multiple: Bool = false {
didSet {
updateStyle()
}
}
private let imageView: UIImageView private let imageView: UIImageView
init() { init(multiple: Bool) {
imageView = UIImageView(image: UIImage(systemName: "checkmark")!) imageView = UIImageView(image: UIImage(systemName: "checkmark")!)
super.init(frame: .zero) super.init(frame: .zero)
let size: CGFloat = 20
layer.cornerRadius = (multiple ? 0.1 : 0.5) * size
layer.cornerCurve = .continuous layer.cornerCurve = .continuous
layer.borderWidth = 2 layer.borderWidth = 2
@ -51,7 +46,7 @@ class PollOptionCheckboxView: UIView {
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
widthAnchor.constraint(equalTo: heightAnchor), widthAnchor.constraint(equalTo: heightAnchor),
widthAnchor.constraint(equalToConstant: PollOptionCheckboxView.size), widthAnchor.constraint(equalToConstant: size),
imageView.widthAnchor.constraint(equalTo: widthAnchor, constant: -3), imageView.widthAnchor.constraint(equalTo: widthAnchor, constant: -3),
imageView.heightAnchor.constraint(equalTo: heightAnchor, constant: -3), imageView.heightAnchor.constraint(equalTo: heightAnchor, constant: -3),
@ -69,8 +64,6 @@ class PollOptionCheckboxView: UIView {
} }
private func updateStyle() { private func updateStyle() {
layer.cornerRadius = (multiple ? 0.1 : 0.5) * PollOptionCheckboxView.size
imageView.isHidden = !isChecked imageView.isHidden = !isChecked
if voted || readOnly { if voted || readOnly {
layer.borderColor = UIColor.clear.cgColor layer.borderColor = UIColor.clear.cgColor

View File

@ -11,43 +11,35 @@ import Pachyderm
class PollOptionView: UIView { class PollOptionView: UIView {
private static let minHeight: CGFloat = 35
private static let cornerRadius = 0.1 * minHeight
private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25) private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
private(set) var label: EmojiLabel! private(set) var label: EmojiLabel!
@Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure { private(set) var checkbox: PollOptionCheckboxView?
$0.translatesAutoresizingMaskIntoConstraints = false
}
var checkboxIfInitialized: PollOptionCheckboxView? {
_checkbox.valueIfInitialized
}
private var percentLabel: UILabel!
@Lazy private var fillView: UIView = UIView().configure {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.backgroundColor = .tintColor.withAlphaComponent(0.6)
$0.layer.zPosition = -1
$0.layer.cornerRadius = PollOptionView.cornerRadius
$0.layer.cornerCurve = .continuous
}
private var labelLeadingToSelfConstraint: NSLayoutConstraint!
private var fillViewWidthConstraint: NSLayoutConstraint?
init() { init(poll: Poll, option: Poll.Option, mastodonController: MastodonController) {
super.init(frame: .zero) super.init(frame: .zero)
layer.cornerRadius = PollOptionView.cornerRadius let minHeight: CGFloat = 35
layer.cornerRadius = 0.1 * minHeight
layer.cornerCurve = .continuous layer.cornerCurve = .continuous
backgroundColor = PollOptionView.unselectedBackgroundColor backgroundColor = PollOptionView.unselectedBackgroundColor
let showCheckbox = poll.ownVotes?.isEmpty == false || !poll.effectiveExpired
if showCheckbox {
checkbox = PollOptionCheckboxView(multiple: poll.multiple)
checkbox!.translatesAutoresizingMaskIntoConstraints = false
addSubview(checkbox!)
}
label = EmojiLabel() label = EmojiLabel()
label.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0 label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .callout) label.font = .preferredFont(forTextStyle: .callout)
label.text = option.title
label.setEmojis(poll.emojis, identifier: poll.id)
addSubview(label) addSubview(label)
labelLeadingToSelfConstraint = label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8)
percentLabel = UILabel() let percentLabel = UILabel()
percentLabel.translatesAutoresizingMaskIntoConstraints = false percentLabel.translatesAutoresizingMaskIntoConstraints = false
percentLabel.text = "100%" percentLabel.text = "100%"
percentLabel.font = label.font percentLabel.font = label.font
@ -56,53 +48,6 @@ class PollOptionView: UIView {
percentLabel.setContentHuggingPriority(.required, for: .horizontal) percentLabel.setContentHuggingPriority(.required, for: .horizontal)
addSubview(percentLabel) addSubview(percentLabel)
let minHeightConstraint = heightAnchor.constraint(greaterThanOrEqualToConstant: PollOptionView.minHeight)
// on the first layout, something is weird and this becomes ambiguous even though it's fine on subsequent layouts
// this keeps autolayout from complaining
minHeightConstraint.priority = .required - 1
NSLayoutConstraint.activate([
minHeightConstraint,
label.topAnchor.constraint(equalTo: topAnchor, constant: 4),
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor, constant: -4),
percentLabel.topAnchor.constraint(equalTo: topAnchor),
percentLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
percentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
isAccessibilityElement = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateUI(poll: Poll, option: Poll.Option, ownVoted: Bool, mastodonController: MastodonController) {
let showCheckbox = poll.ownVotes?.isEmpty == false || !poll.effectiveExpired
if showCheckbox {
checkbox.isChecked = ownVoted
checkbox.voted = poll.voted ?? false
labelLeadingToSelfConstraint.isActive = false
if checkbox.superview != self {
addSubview(checkbox)
NSLayoutConstraint.activate([
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8),
])
}
} else if !showCheckbox {
labelLeadingToSelfConstraint.isActive = true
_checkbox.valueIfInitialized?.removeFromSuperview()
}
label.text = option.title
label.setEmojis(poll.emojis, identifier: poll.id)
accessibilityLabel = option.title accessibilityLabel = option.title
if (poll.voted ?? false) || poll.effectiveExpired, if (poll.voted ?? false) || poll.effectiveExpired,
@ -120,23 +65,56 @@ class PollOptionView: UIView {
percentLabel.isHidden = false percentLabel.isHidden = false
percentLabel.text = percent percentLabel.text = percent
if fillView.superview != self { let fillView = UIView()
addSubview(fillView) fillView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([ fillView.backgroundColor = .tintColor.withAlphaComponent(0.6)
fillView.leadingAnchor.constraint(equalTo: leadingAnchor), fillView.layer.zPosition = -1
fillView.topAnchor.constraint(equalTo: topAnchor), fillView.layer.cornerRadius = layer.cornerRadius
fillView.bottomAnchor.constraint(equalTo: bottomAnchor), fillView.layer.cornerCurve = .continuous
]) addSubview(fillView)
}
fillViewWidthConstraint?.isActive = false NSLayoutConstraint.activate([
fillViewWidthConstraint = fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: frac) fillView.leadingAnchor.constraint(equalTo: leadingAnchor),
fillViewWidthConstraint!.isActive = true fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: frac),
fillView.topAnchor.constraint(equalTo: topAnchor),
fillView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
accessibilityLabel! += ", \(percent)" accessibilityLabel! += ", \(percent)"
} else {
percentLabel.isHidden = true
_fillView.valueIfInitialized?.removeFromSuperview()
} }
let minHeightConstraint = heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
// on the first layout, something is weird and this becomes ambiguous even though it's fine on subsequent layouts
// this keeps autolayout from complaining
minHeightConstraint.priority = .required - 1
NSLayoutConstraint.activate([
minHeightConstraint,
label.topAnchor.constraint(equalTo: topAnchor, constant: 4),
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor, constant: -4),
percentLabel.topAnchor.constraint(equalTo: topAnchor),
percentLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
percentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
if let checkbox {
NSLayoutConstraint.activate([
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8),
])
} else {
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8).isActive = true
}
isAccessibilityElement = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
} }
} }

View File

@ -14,7 +14,7 @@ class PollOptionsView: UIControl {
var mastodonController: MastodonController! var mastodonController: MastodonController!
var checkedOptionIndices: [Int] { var checkedOptionIndices: [Int] {
options.enumerated().filter { $0.element.checkboxIfInitialized?.isChecked == true }.map(\.offset) options.enumerated().filter { $0.element.checkbox?.isChecked == true }.map(\.offset)
} }
var checkedOptionsChanged: (() -> Void)? var checkedOptionsChanged: (() -> Void)?
@ -32,15 +32,10 @@ class PollOptionsView: UIControl {
override var isEnabled: Bool { override var isEnabled: Bool {
didSet { didSet {
options.forEach { $0.checkboxIfInitialized?.readOnly = !isEnabled } options.forEach { $0.checkbox?.readOnly = !isEnabled }
} }
} }
override var accessibilityElements: [Any]? {
get { options }
set {}
}
override init(frame: CGRect) { override init(frame: CGRect) {
stack = UIStackView() stack = UIStackView()
@ -66,22 +61,20 @@ class PollOptionsView: UIControl {
func updateUI(poll: Poll) { func updateUI(poll: Poll) {
self.poll = poll self.poll = poll
if poll.options.count > options.count { options.forEach { $0.removeFromSuperview() }
for _ in 0..<(poll.options.count - options.count) {
let optView = PollOptionView() options = poll.options.enumerated().map { (index, opt) in
options.append(optView) let optionView = PollOptionView(poll: poll, option: opt, mastodonController: mastodonController)
stack.addArrangedSubview(optView) if let checkbox = optionView.checkbox {
} checkbox.readOnly = !isEnabled
} else if poll.options.count < options.count { checkbox.isChecked = poll.ownVotes?.contains(index) ?? false
for _ in 0..<(options.count - poll.options.count) { checkbox.voted = poll.voted ?? false
options.removeLast().removeFromSuperview()
} }
stack.addArrangedSubview(optionView)
return optionView
} }
for (index, (view, opt)) in zip(options, poll.options).enumerated() { accessibilityElements = options
view.updateUI(poll: poll, option: opt, ownVoted: poll.ownVotes?.contains(index) ?? false, mastodonController: mastodonController)
view.checkboxIfInitialized?.readOnly = !isEnabled
}
} }
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat { func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
@ -96,13 +89,13 @@ class PollOptionsView: UIControl {
private func selectOption(_ option: PollOptionView) { private func selectOption(_ option: PollOptionView) {
if poll.multiple { if poll.multiple {
option.checkboxIfInitialized?.isChecked.toggle() option.checkbox?.isChecked.toggle()
} else { } else {
for opt in options { for opt in options {
if opt === option { if opt === option {
opt.checkboxIfInitialized?.isChecked = true opt.checkbox?.isChecked = true
} else { } else {
opt.checkboxIfInitialized?.isChecked = false opt.checkbox?.isChecked = false
} }
} }
} }

View File

@ -60,8 +60,7 @@ extension StatusCollectionViewCell {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID } .filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] _ in .sink { [unowned self] _ in
if let mastodonController = self.mastodonController, if let status = self.mastodonController.persistentContainer.status(for: self.statusID) {
let status = mastodonController.persistentContainer.status(for: self.statusID) {
// update immediately w/o animation // update immediately w/o animation
self.favoriteButton.active = status.favourited self.favoriteButton.active = status.favourited
self.reblogButton.active = status.reblogged self.reblogButton.active = status.reblogged