forked from shadowfacts/Tusker
Status cell interaction
This commit is contained in:
parent
780e8b09b7
commit
24e90de672
|
@ -149,6 +149,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
collectionView.indexPathsForSelectedItems?.forEach {
|
||||
collectionView.deselectItem(at: $0, animated: true)
|
||||
}
|
||||
|
||||
Task {
|
||||
await controller.loadInitial()
|
||||
}
|
||||
|
@ -220,6 +224,15 @@ extension TimelineViewController {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isSelectable: Bool {
|
||||
switch self {
|
||||
case .publicTimelineDescription, .status(id: _, state: _):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -310,6 +323,10 @@ extension TimelineViewController: UICollectionViewDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||
return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||
return
|
||||
|
@ -317,10 +334,10 @@ extension TimelineViewController: UICollectionViewDelegate {
|
|||
switch item {
|
||||
case .publicTimelineDescription:
|
||||
removeTimelineDescriptionCell()
|
||||
|
||||
default:
|
||||
// TODO: cell selection
|
||||
break
|
||||
case .status(id: let id, state: let state):
|
||||
selected(status: id, state: state.copy())
|
||||
case .loadingIndicator, .confirmLoadMore:
|
||||
fatalError("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -339,11 +356,11 @@ extension TimelineViewController: MenuActionProvider {
|
|||
}
|
||||
|
||||
extension TimelineViewController: TimelineStatusCollectionViewCellDelegate {
|
||||
func statusCellCollapsedStateChanged(_ cell: TimelineStatusCollectionViewCell) {
|
||||
func statusCellNeedsReconfigure(_ cell: TimelineStatusCollectionViewCell, animated: Bool) {
|
||||
if let indexPath = collectionView.indexPath(for: cell) {
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,19 +11,18 @@ import Pachyderm
|
|||
|
||||
@MainActor
|
||||
protocol TimelineStatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider {
|
||||
func statusCellCollapsedStateChanged(_ cell: TimelineStatusCollectionViewCell)
|
||||
func statusCellNeedsReconfigure(_ cell: TimelineStatusCollectionViewCell, animated: Bool)
|
||||
}
|
||||
|
||||
class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
||||
|
||||
// MARK: Subviews
|
||||
|
||||
private let reblogLabel = EmojiLabel().configure {
|
||||
private lazy var reblogLabel = EmojiLabel().configure {
|
||||
$0.textColor = .secondaryLabel
|
||||
// this needs to have a higher priorty than the content container's zero height constraint
|
||||
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||
// TODO: tap gesture
|
||||
// $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed)))
|
||||
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed)))
|
||||
}
|
||||
|
||||
private lazy var mainContainer = UIView().configure {
|
||||
|
@ -47,18 +46,16 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
|||
}
|
||||
|
||||
private static let avatarImageViewSize: CGFloat = 50
|
||||
private let avatarImageView = CachedImageView(cache: .avatars).configure {
|
||||
private lazy var avatarImageView = CachedImageView(cache: .avatars).configure {
|
||||
$0.layer.masksToBounds = true
|
||||
NSLayoutConstraint.activate([
|
||||
$0.heightAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.avatarImageViewSize),
|
||||
$0.widthAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.avatarImageViewSize),
|
||||
])
|
||||
// TODO: context menu
|
||||
// $0.addInteraction(UIContextMenuInteraction(delegate: self))
|
||||
// TODO: drag gesture
|
||||
// $0.addInteraction(UIDragInteraction(delegate: self))
|
||||
// TODO: tap gesture
|
||||
// $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
|
||||
$0.isUserInteractionEnabled = true
|
||||
$0.addInteraction(UIContextMenuInteraction(delegate: self))
|
||||
$0.addInteraction(UIDragInteraction(delegate: self))
|
||||
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
|
||||
}
|
||||
|
||||
private let metaIndicatorsView = StatusMetaIndicatorsView()
|
||||
|
@ -82,8 +79,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
|||
]).configure {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 4
|
||||
// TODO: tap gesture
|
||||
// $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
|
||||
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
|
||||
}
|
||||
|
||||
private let displayNameLabel = EmojiLabel().configure {
|
||||
|
@ -194,16 +190,19 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
|||
])
|
||||
}
|
||||
|
||||
private let replyButton = UIButton().configure {
|
||||
private lazy var replyButton = UIButton().configure {
|
||||
$0.setImage(UIImage(systemName: "arrowshape.turn.up.left.fill"), for: .normal)
|
||||
$0.addTarget(self, action: #selector(replyPressed), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private let favoriteButton = UIButton().configure {
|
||||
private lazy var favoriteButton = UIButton().configure {
|
||||
$0.setImage(UIImage(systemName: "star.fill"), for: .normal)
|
||||
$0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private let reblogButton = UIButton().configure {
|
||||
private lazy var reblogButton = UIButton().configure {
|
||||
$0.setImage(UIImage(systemName: "repeat"), for: .normal)
|
||||
$0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private let moreButton = UIButton().configure {
|
||||
|
@ -241,6 +240,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
|||
|
||||
private var firstLayout = true
|
||||
private var isGrayscale = false
|
||||
|
||||
private var updateTimestampWorkItem: DispatchWorkItem?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
@ -316,6 +317,39 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Accessibility
|
||||
|
||||
override var accessibilityLabel: String? {
|
||||
get {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
return nil
|
||||
}
|
||||
var str = "\(status.account.displayOrUserName), \(contentTextView.text ?? "")"
|
||||
|
||||
if status.attachments.count > 0 {
|
||||
// TODO: localize me
|
||||
str += ", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")"
|
||||
}
|
||||
if status.poll != nil {
|
||||
str += ", poll"
|
||||
}
|
||||
str += ", \(status.createdAt.formatted(.relative(presentation: .numeric)))"
|
||||
if let rebloggerID,
|
||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||
str += ", reblogged by \(reblogger.displayOrUserName)"
|
||||
}
|
||||
return str
|
||||
}
|
||||
set {}
|
||||
}
|
||||
|
||||
override func accessibilityActivate() -> Bool {
|
||||
delegate?.selected(status: statusID, state: statusState.copy())
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Configure UI
|
||||
|
||||
func updateUI(statusID: String, state: StatusState) {
|
||||
guard var status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
fatalError()
|
||||
|
@ -353,6 +387,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
|||
updateGrayscaleableUI(account: account, status: status)
|
||||
updateUIForPreferences(account: account, status: status)
|
||||
|
||||
doUpdateTimestamp(status: status)
|
||||
|
||||
timestampLabel.isHidden = showPinned
|
||||
pinImageView.isHidden = !showPinned
|
||||
|
||||
cardView.card = status.card
|
||||
cardView.isHidden = status.card == nil
|
||||
cardView.navigationDelegate = delegate
|
||||
|
@ -395,7 +434,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
|||
}
|
||||
}
|
||||
|
||||
func updateUIForPreferences(account: AccountMO, status: StatusMO) {
|
||||
private func updateUIForPreferences(account: AccountMO, status: StatusMO) {
|
||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * TimelineStatusCollectionViewCell.avatarImageViewSize
|
||||
attachmentsView.contentHidden = Preferences.shared.blurAllMedia || status.sensitive
|
||||
|
||||
|
@ -415,6 +454,38 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
|||
}
|
||||
}
|
||||
|
||||
private func updateTimestamp() {
|
||||
guard let mastodonController,
|
||||
let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
return
|
||||
}
|
||||
doUpdateTimestamp(status: status)
|
||||
}
|
||||
|
||||
private func doUpdateTimestamp(status: StatusMO) {
|
||||
timestampLabel.text = status.createdAt.timeAgoString()
|
||||
|
||||
let delay: DispatchTimeInterval?
|
||||
switch status.createdAt.timeAgo().1 {
|
||||
case .second:
|
||||
delay = .seconds(10)
|
||||
case .minute:
|
||||
delay = .seconds(60)
|
||||
default:
|
||||
delay = nil
|
||||
}
|
||||
if let delay {
|
||||
if updateTimestampWorkItem == nil {
|
||||
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
|
||||
self?.updateTimestamp()
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
|
||||
} else {
|
||||
updateTimestampWorkItem = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRebloggerLabel(reblogger: AccountMO) {
|
||||
if Preferences.shared.hideCustomEmojiInUsernames {
|
||||
reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)"
|
||||
|
@ -484,16 +555,157 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell {
|
|||
let oldState = actionsContainer.isHidden
|
||||
if oldState != Preferences.shared.hideActionsInTimeline {
|
||||
updateActionsVisibility()
|
||||
delegate?.statusCellCollapsedStateChanged(self)
|
||||
delegate?.statusCellNeedsReconfigure(self, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
@objc func collapseButtonPressed() {
|
||||
@objc private func reblogLabelPressed() {
|
||||
guard let rebloggerID else {
|
||||
return
|
||||
}
|
||||
delegate?.selected(account: rebloggerID)
|
||||
}
|
||||
|
||||
@objc private func accountPressed() {
|
||||
delegate?.selected(account: accountID)
|
||||
}
|
||||
|
||||
@objc private func collapseButtonPressed() {
|
||||
statusState.collapsed!.toggle()
|
||||
// this delegate call causes the collection view to reconfigure this cell, at which point (and inside of the collection view's animation handling) we'll update the contentContainer
|
||||
delegate?.statusCellCollapsedStateChanged(self)
|
||||
delegate?.statusCellNeedsReconfigure(self, animated: true)
|
||||
}
|
||||
|
||||
@objc private func replyPressed() {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc private func favoritePressed() {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
fatalError()
|
||||
}
|
||||
let oldValue = status.favourited
|
||||
status.favourited.toggle()
|
||||
// update ui before network request to make things appear speedy
|
||||
updateStatusState(status: status)
|
||||
|
||||
let request = (status.favourited ? Status.favourite : Status.unfavourite)(statusID)
|
||||
Task {
|
||||
do {
|
||||
let (newStatus, _) = try await mastodonController.run(request)
|
||||
mastodonController.persistentContainer.addOrUpdate(status: newStatus)
|
||||
// TODO: should this before the network request
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
} catch {
|
||||
status.favourited = oldValue
|
||||
// TODO: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func reblogPressed() {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
if !status.reblogged,
|
||||
Preferences.shared.confirmBeforeReblog {
|
||||
let image: UIImage?
|
||||
let reblogVisibilityActions: [CustomAlertController.MenuAction]?
|
||||
if mastodonController.instanceFeatures.reblogVisibility {
|
||||
image = UIImage(systemName: Status.Visibility.public.unfilledImageName)
|
||||
reblogVisibilityActions = [Status.Visibility.unlisted, .private].map { visibility in
|
||||
CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) { [unowned self] in
|
||||
self.doReblog(status: status, visibility: visibility)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
image = nil
|
||||
reblogVisibilityActions = []
|
||||
}
|
||||
|
||||
let preview = ConfirmReblogStatusPreviewView(status: status)
|
||||
var config = CustomAlertController.Configuration(title: "Are you sure you want to reblog this post?", content: preview, actions: [
|
||||
CustomAlertController.Action(title: "Cancel", style: .cancel, handler: nil),
|
||||
CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { [unowned self] in
|
||||
self.doReblog(status: status, visibility: nil)
|
||||
})
|
||||
])
|
||||
if let reblogVisibilityActions {
|
||||
var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil)
|
||||
menuAction.isSecondaryMenu = true
|
||||
config.actions.append(menuAction)
|
||||
}
|
||||
let alert = CustomAlertController(config: config)
|
||||
delegate?.present(alert, animated: true)
|
||||
} else {
|
||||
doReblog(status: status, visibility: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func doReblog(status: StatusMO, visibility: Status.Visibility?) {
|
||||
let oldValue = status.reblogged
|
||||
status.reblogged.toggle()
|
||||
updateStatusState(status: status)
|
||||
|
||||
let request: Request<Status>
|
||||
if status.reblogged {
|
||||
request = Status.reblog(statusID, visibility: visibility)
|
||||
} else {
|
||||
request = Status.unreblog(statusID)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
let (newStatus, _) = try await mastodonController.run(request)
|
||||
mastodonController.persistentContainer.addOrUpdate(status: newStatus)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
} catch {
|
||||
status.reblogged = oldValue
|
||||
// TODO: display error message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TimelineStatusCollectionViewCell: UIContextMenuInteractionDelegate {
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return UIContextMenuConfiguration() {
|
||||
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
|
||||
} actionProvider: { _ in
|
||||
return UIMenu(children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: interaction.view!) ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
if let viewController = animator.previewViewController,
|
||||
let delegate {
|
||||
animator.preferredCommitStyle = .pop
|
||||
animator.addCompletion {
|
||||
if let customPresenting = viewController as? CustomPreviewPresenting {
|
||||
customPresenting.presentFromPreview(presenter: delegate)
|
||||
} else {
|
||||
delegate.show(viewController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineStatusCollectionViewCell: UIDragInteractionDelegate {
|
||||
func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] {
|
||||
guard let currentAccountID = mastodonController.accountInfo?.id,
|
||||
let account = mastodonController.persistentContainer.account(for: accountID) else {
|
||||
return []
|
||||
}
|
||||
let provider = NSItemProvider(object: account.url as NSURL)
|
||||
let activity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue