2021-01-28 09:10:30 +01:00
|
|
|
//
|
|
|
|
// TimelineSection.swift
|
|
|
|
// Mastodon
|
|
|
|
//
|
|
|
|
// Created by sxiaojian on 2021/1/27.
|
|
|
|
//
|
|
|
|
|
|
|
|
import Combine
|
|
|
|
import CoreData
|
|
|
|
import CoreDataStack
|
|
|
|
import os.log
|
|
|
|
import UIKit
|
2021-04-19 12:06:02 +02:00
|
|
|
import AVKit
|
2021-06-16 13:58:28 +02:00
|
|
|
import Nuke
|
2021-06-23 15:21:22 +02:00
|
|
|
import LinkPresentation
|
2021-04-19 12:06:02 +02:00
|
|
|
|
2021-06-22 07:41:40 +02:00
|
|
|
#if ASDK
|
|
|
|
import AsyncDisplayKit
|
|
|
|
#endif
|
|
|
|
|
2021-04-30 13:28:06 +02:00
|
|
|
protocol StatusCell: DisposeBagCollectable {
|
2021-04-19 12:06:02 +02:00
|
|
|
var statusView: StatusView { get }
|
|
|
|
}
|
2021-01-28 09:10:30 +01:00
|
|
|
|
2021-02-24 09:11:48 +01:00
|
|
|
enum StatusSection: Equatable, Hashable {
|
2021-01-28 09:10:30 +01:00
|
|
|
case main
|
|
|
|
}
|
|
|
|
|
2021-02-24 09:11:48 +01:00
|
|
|
extension StatusSection {
|
2021-06-22 07:41:40 +02:00
|
|
|
#if ASDK
|
2021-06-19 12:33:29 +02:00
|
|
|
static func tableNodeDiffableDataSource(
|
|
|
|
tableNode: ASTableNode,
|
|
|
|
managedObjectContext: NSManagedObjectContext
|
|
|
|
) -> TableNodeDiffableDataSource<StatusSection, Item> {
|
|
|
|
TableNodeDiffableDataSource(tableNode: tableNode) { tableNode, indexPath, item in
|
|
|
|
switch item {
|
|
|
|
case .homeTimelineIndex(let objectID, let attribute):
|
|
|
|
guard let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else {
|
|
|
|
return { ASCellNode() }
|
|
|
|
}
|
|
|
|
let status = homeTimelineIndex.status
|
|
|
|
|
|
|
|
return { () -> ASCellNode in
|
|
|
|
let cellNode = StatusNode(status: status)
|
|
|
|
return cellNode
|
|
|
|
}
|
|
|
|
case .homeMiddleLoader:
|
|
|
|
return { TimelineMiddleLoaderNode() }
|
|
|
|
case .bottomLoader:
|
|
|
|
return { TimelineBottomLoaderNode() }
|
|
|
|
default:
|
|
|
|
return { ASCellNode() }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2021-06-22 07:41:40 +02:00
|
|
|
#endif
|
2021-06-19 12:33:29 +02:00
|
|
|
|
2021-01-28 09:10:30 +01:00
|
|
|
static func tableViewDiffableDataSource(
|
|
|
|
for tableView: UITableView,
|
|
|
|
dependency: NeedsDependency,
|
|
|
|
managedObjectContext: NSManagedObjectContext,
|
|
|
|
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
2021-03-03 09:12:48 +01:00
|
|
|
statusTableViewCellDelegate: StatusTableViewCellDelegate,
|
2021-04-13 13:46:42 +02:00
|
|
|
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?,
|
|
|
|
threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate?
|
2021-02-24 09:11:48 +01:00
|
|
|
) -> UITableViewDiffableDataSource<StatusSection, Item> {
|
2021-04-13 13:46:42 +02:00
|
|
|
UITableViewDiffableDataSource(tableView: tableView) { [
|
|
|
|
weak dependency,
|
|
|
|
weak statusTableViewCellDelegate,
|
|
|
|
weak timelineMiddleLoaderTableViewCellDelegate,
|
|
|
|
weak threadReplyLoaderTableViewCellDelegate
|
|
|
|
] tableView, indexPath, item -> UITableViewCell? in
|
|
|
|
guard let dependency = dependency else { return UITableViewCell() }
|
2021-03-03 09:12:48 +01:00
|
|
|
guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() }
|
2021-01-28 09:10:30 +01:00
|
|
|
|
|
|
|
switch item {
|
2021-02-24 09:11:48 +01:00
|
|
|
case .homeTimelineIndex(objectID: let objectID, let attribute):
|
2021-02-23 08:16:55 +01:00
|
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
2021-06-23 14:47:49 +02:00
|
|
|
let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex
|
2021-02-07 07:42:50 +01:00
|
|
|
|
2021-06-23 14:47:49 +02:00
|
|
|
// note: force check optional for status
|
|
|
|
// status maybe <uninitialized> here when delete in thread scene
|
|
|
|
guard let status = timelineIndex?.status,
|
|
|
|
let userID = timelineIndex?.userID else {
|
|
|
|
return cell
|
2021-02-07 07:42:50 +01:00
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
|
|
|
|
// configure cell
|
|
|
|
configureStatusTableViewCell(
|
|
|
|
cell: cell,
|
|
|
|
dependency: dependency,
|
|
|
|
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
|
|
|
status: status,
|
|
|
|
requestUserID: userID,
|
|
|
|
statusItemAttribute: attribute
|
|
|
|
)
|
2021-03-03 09:12:48 +01:00
|
|
|
cell.delegate = statusTableViewCellDelegate
|
2021-05-12 12:26:53 +02:00
|
|
|
cell.isAccessibilityElement = true
|
2021-02-07 07:42:50 +01:00
|
|
|
return cell
|
2021-04-13 13:46:42 +02:00
|
|
|
case .status(let objectID, let attribute),
|
|
|
|
.root(let objectID, let attribute),
|
|
|
|
.reply(let objectID, let attribute),
|
|
|
|
.leaf(let objectID, let attribute):
|
2021-02-23 08:16:55 +01:00
|
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
2021-02-08 11:29:27 +01:00
|
|
|
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
|
|
|
|
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
|
2021-01-28 09:10:30 +01:00
|
|
|
// configure cell
|
|
|
|
managedObjectContext.performAndWait {
|
2021-04-01 08:39:15 +02:00
|
|
|
let status = managedObjectContext.object(with: objectID) as! Status
|
2021-03-10 07:36:28 +01:00
|
|
|
StatusSection.configure(
|
|
|
|
cell: cell,
|
|
|
|
dependency: dependency,
|
2021-03-10 14:19:56 +01:00
|
|
|
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
2021-04-01 08:39:15 +02:00
|
|
|
status: status,
|
2021-03-10 14:19:56 +01:00
|
|
|
requestUserID: requestUserID,
|
|
|
|
statusItemAttribute: attribute
|
2021-03-10 07:36:28 +01:00
|
|
|
)
|
2021-04-13 13:46:42 +02:00
|
|
|
|
|
|
|
switch item {
|
|
|
|
case .root:
|
|
|
|
StatusSection.configureThreadMeta(cell: cell, status: status)
|
|
|
|
ManagedObjectObserver.observe(object: status.reblog ?? status)
|
2021-06-16 12:32:48 +02:00
|
|
|
.receive(on: RunLoop.main)
|
2021-04-13 13:46:42 +02:00
|
|
|
.sink { _ in
|
|
|
|
// do nothing
|
|
|
|
} receiveValue: { change in
|
|
|
|
guard case .update(let object) = change.changeType,
|
|
|
|
let status = object as? Status else { return }
|
|
|
|
StatusSection.configureThreadMeta(cell: cell, status: status)
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
2021-01-28 09:10:30 +01:00
|
|
|
}
|
2021-03-03 09:12:48 +01:00
|
|
|
cell.delegate = statusTableViewCellDelegate
|
2021-05-12 12:26:53 +02:00
|
|
|
switch item {
|
|
|
|
case .root:
|
|
|
|
cell.statusView.activeTextLabel.isAccessibilityElement = false
|
|
|
|
var accessibilityElements: [Any] = []
|
2021-05-13 08:27:57 +02:00
|
|
|
accessibilityElements.append(cell.statusView.avatarView)
|
2021-05-12 12:26:53 +02:00
|
|
|
accessibilityElements.append(cell.statusView.nameLabel)
|
|
|
|
accessibilityElements.append(cell.statusView.dateLabel)
|
|
|
|
accessibilityElements.append(contentsOf: cell.statusView.activeTextLabel.createAccessibilityElements())
|
|
|
|
accessibilityElements.append(contentsOf: cell.statusView.statusMosaicImageViewContainer.imageViews)
|
|
|
|
accessibilityElements.append(cell.statusView.playerContainerView)
|
|
|
|
accessibilityElements.append(cell.statusView.actionToolbarContainer)
|
|
|
|
accessibilityElements.append(cell.threadMetaView)
|
|
|
|
cell.accessibilityElements = accessibilityElements
|
|
|
|
default:
|
|
|
|
cell.isAccessibilityElement = true
|
|
|
|
cell.accessibilityElements = nil
|
|
|
|
}
|
2021-04-13 13:46:42 +02:00
|
|
|
return cell
|
|
|
|
case .leafBottomLoader:
|
|
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self), for: indexPath) as! ThreadReplyLoaderTableViewCell
|
|
|
|
cell.delegate = threadReplyLoaderTableViewCellDelegate
|
2021-01-28 09:10:30 +01:00
|
|
|
return cell
|
2021-04-01 08:39:15 +02:00
|
|
|
case .publicMiddleLoader(let upperTimelineStatusID):
|
2021-02-07 07:42:50 +01:00
|
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
|
|
|
|
cell.delegate = timelineMiddleLoaderTableViewCellDelegate
|
2021-04-01 08:39:15 +02:00
|
|
|
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: upperTimelineStatusID, timelineIndexobjectID: nil)
|
2021-02-07 07:42:50 +01:00
|
|
|
return cell
|
|
|
|
case .homeMiddleLoader(let upperTimelineIndexObjectID):
|
2021-02-04 08:09:58 +01:00
|
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
|
|
|
|
cell.delegate = timelineMiddleLoaderTableViewCellDelegate
|
2021-04-01 08:39:15 +02:00
|
|
|
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID)
|
2021-02-04 08:09:58 +01:00
|
|
|
return cell
|
2021-04-13 13:46:42 +02:00
|
|
|
case .topLoader:
|
|
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
|
|
|
cell.startAnimating()
|
|
|
|
return cell
|
2021-02-03 06:01:50 +01:00
|
|
|
case .bottomLoader:
|
|
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
2021-03-16 12:28:52 +01:00
|
|
|
cell.startAnimating()
|
2021-02-03 06:01:50 +01:00
|
|
|
return cell
|
2021-04-06 10:43:08 +02:00
|
|
|
case .emptyStateHeader(let attribute):
|
|
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell
|
|
|
|
StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute)
|
|
|
|
return cell
|
2021-04-23 03:37:18 +02:00
|
|
|
case .reportStatus:
|
|
|
|
return UITableViewCell()
|
2021-01-28 09:10:30 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2021-03-03 12:34:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
extension StatusSection {
|
2021-06-23 14:47:49 +02:00
|
|
|
|
|
|
|
static func configureStatusTableViewCell(
|
|
|
|
cell: StatusTableViewCell,
|
|
|
|
dependency: NeedsDependency,
|
|
|
|
readableLayoutFrame: CGRect?,
|
|
|
|
status: Status,
|
|
|
|
requestUserID: String,
|
|
|
|
statusItemAttribute: Item.StatusAttribute
|
|
|
|
) {
|
|
|
|
configure(
|
|
|
|
cell: cell,
|
|
|
|
dependency: dependency,
|
|
|
|
readableLayoutFrame: readableLayoutFrame,
|
|
|
|
status: status,
|
|
|
|
requestUserID: requestUserID,
|
|
|
|
statusItemAttribute: statusItemAttribute
|
|
|
|
)
|
|
|
|
}
|
2021-04-06 10:43:08 +02:00
|
|
|
|
2021-01-28 09:10:30 +01:00
|
|
|
static func configure(
|
2021-04-19 12:06:02 +02:00
|
|
|
cell: StatusCell,
|
2021-03-10 07:36:28 +01:00
|
|
|
dependency: NeedsDependency,
|
2021-02-23 12:18:34 +01:00
|
|
|
readableLayoutFrame: CGRect?,
|
2021-04-01 08:39:15 +02:00
|
|
|
status: Status,
|
2021-02-24 09:11:48 +01:00
|
|
|
requestUserID: String,
|
2021-03-05 06:41:48 +01:00
|
|
|
statusItemAttribute: Item.StatusAttribute
|
2021-04-30 13:28:06 +02:00
|
|
|
) {
|
2021-06-16 12:32:48 +02:00
|
|
|
// safely cancel the listener when deleted
|
2021-05-08 04:38:55 +02:00
|
|
|
ManagedObjectObserver.observe(object: status.reblog ?? status)
|
2021-06-16 12:32:48 +02:00
|
|
|
.receive(on: RunLoop.main)
|
2021-05-08 04:38:55 +02:00
|
|
|
.sink { _ in
|
|
|
|
// do nothing
|
|
|
|
} receiveValue: { [weak cell] change in
|
|
|
|
guard let cell = cell else { return }
|
|
|
|
guard let changeType = change.changeType else { return }
|
|
|
|
if case .delete = changeType {
|
|
|
|
cell.disposeBag.removeAll()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
|
|
|
|
2021-02-23 08:16:55 +01:00
|
|
|
// set header
|
2021-06-23 14:47:49 +02:00
|
|
|
StatusSection.configureStatusViewHeader(cell: cell, status: status)
|
|
|
|
// set author: name + username + avatar
|
|
|
|
StatusSection.configureStatusViewAuthor(cell: cell, status: status)
|
|
|
|
// set timestamp
|
|
|
|
let createdAt = (status.reblog ?? status).createdAt
|
|
|
|
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
|
|
|
|
AppContext.shared.timestampUpdatePublisher
|
|
|
|
.receive(on: RunLoop.main) // will be paused when scrolling (on purpose)
|
|
|
|
.sink { [weak cell] _ in
|
|
|
|
guard let cell = cell else { return }
|
|
|
|
cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
|
|
|
|
cell.statusView.dateLabel.accessibilityLabel = createdAt.slowedTimeAgoSinceNow
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
|
|
|
// set content
|
|
|
|
StatusSection.configureStatusContent(
|
|
|
|
cell: cell,
|
|
|
|
status: status,
|
|
|
|
readableLayoutFrame: readableLayoutFrame,
|
|
|
|
statusItemAttribute: statusItemAttribute
|
|
|
|
)
|
|
|
|
// set content warning
|
|
|
|
StatusSection.configureContentWarningOverlay(
|
|
|
|
statusView: cell.statusView,
|
|
|
|
status: status,
|
|
|
|
attribute: statusItemAttribute,
|
|
|
|
documentStore: dependency.context.documentStore,
|
|
|
|
animated: false
|
|
|
|
)
|
|
|
|
// set poll
|
|
|
|
StatusSection.configurePoll(
|
|
|
|
cell: cell,
|
|
|
|
poll: (status.reblog ?? status).poll,
|
|
|
|
requestUserID: requestUserID,
|
|
|
|
updateProgressAnimated: false
|
|
|
|
)
|
|
|
|
if let poll = (status.reblog ?? status).poll {
|
|
|
|
ManagedObjectObserver.observe(object: poll)
|
|
|
|
.sink { _ in
|
|
|
|
// do nothing
|
|
|
|
} receiveValue: { [weak cell] change in
|
|
|
|
guard let cell = cell else { return }
|
|
|
|
guard case .update(let object) = change.changeType,
|
|
|
|
let newPoll = object as? Poll else { return }
|
|
|
|
StatusSection.configurePoll(
|
|
|
|
cell: cell,
|
|
|
|
poll: newPoll,
|
|
|
|
requestUserID: requestUserID,
|
|
|
|
updateProgressAnimated: true
|
|
|
|
)
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
|
|
|
}
|
|
|
|
// set action toolbar
|
|
|
|
if let cell = cell as? StatusTableViewCell {
|
|
|
|
StatusSection.configureActionToolBar(
|
|
|
|
cell: cell,
|
|
|
|
dependency: dependency,
|
|
|
|
status: status,
|
|
|
|
requestUserID: requestUserID
|
|
|
|
)
|
|
|
|
|
|
|
|
// separator line
|
|
|
|
cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden
|
|
|
|
}
|
|
|
|
|
|
|
|
// listen model changed
|
2021-04-01 08:39:15 +02:00
|
|
|
ManagedObjectObserver.observe(object: status)
|
2021-06-16 12:32:48 +02:00
|
|
|
.receive(on: RunLoop.main)
|
2021-03-10 12:12:53 +01:00
|
|
|
.sink { _ in
|
|
|
|
// do nothing
|
2021-04-26 11:41:24 +02:00
|
|
|
} receiveValue: { [weak cell] change in
|
|
|
|
guard let cell = cell else { return }
|
2021-03-10 12:12:53 +01:00
|
|
|
guard case .update(let object) = change.changeType,
|
2021-06-23 14:47:49 +02:00
|
|
|
let status = object as? Status, !status.isDeleted else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// update header
|
|
|
|
StatusSection.configureStatusViewHeader(cell: cell, status: status)
|
2021-03-10 12:12:53 +01:00
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
2021-06-23 14:47:49 +02:00
|
|
|
ManagedObjectObserver.observe(object: status.reblog ?? status)
|
|
|
|
.receive(on: RunLoop.main)
|
|
|
|
.sink { _ in
|
|
|
|
// do nothing
|
|
|
|
} receiveValue: { [weak cell] change in
|
2021-06-17 10:31:34 +02:00
|
|
|
guard let cell = cell else { return }
|
2021-06-23 14:47:49 +02:00
|
|
|
guard case .update(let object) = change.changeType,
|
|
|
|
let status = object as? Status, !status.isDeleted else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// update content warning overlay
|
|
|
|
StatusSection.configureContentWarningOverlay(
|
|
|
|
statusView: cell.statusView,
|
|
|
|
status: status,
|
|
|
|
attribute: statusItemAttribute,
|
|
|
|
documentStore: dependency.context.documentStore,
|
|
|
|
animated: true
|
|
|
|
)
|
|
|
|
// update action toolbar
|
|
|
|
if let cell = cell as? StatusTableViewCell {
|
|
|
|
StatusSection.configureActionToolBar(
|
|
|
|
cell: cell,
|
|
|
|
dependency: dependency,
|
|
|
|
status: status,
|
|
|
|
requestUserID: requestUserID
|
|
|
|
)
|
|
|
|
}
|
2021-06-17 10:31:34 +02:00
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
2021-06-23 14:47:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
static func configureContentWarningOverlay(
|
|
|
|
statusView: StatusView,
|
|
|
|
status: Status,
|
|
|
|
attribute: Item.StatusAttribute,
|
|
|
|
documentStore: DocumentStore,
|
|
|
|
animated: Bool
|
|
|
|
) {
|
|
|
|
statusView.contentWarningOverlayView.blurContentWarningTitleLabel.text = {
|
|
|
|
let spoilerText = (status.reblog ?? status).spoilerText ?? ""
|
|
|
|
if spoilerText.isEmpty {
|
|
|
|
return L10n.Common.Controls.Status.contentWarning
|
|
|
|
} else {
|
|
|
|
return spoilerText
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
let appStartUpTimestamp = documentStore.appStartUpTimestamp
|
2021-02-23 12:18:34 +01:00
|
|
|
|
2021-06-23 14:47:49 +02:00
|
|
|
switch (status.reblog ?? status).sensitiveType {
|
|
|
|
case .none:
|
|
|
|
statusView.revealContentWarningButton.isHidden = true
|
|
|
|
statusView.contentWarningOverlayView.isHidden = true
|
|
|
|
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true
|
|
|
|
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
|
|
|
case .all:
|
|
|
|
statusView.revealContentWarningButton.isHidden = false
|
|
|
|
statusView.contentWarningOverlayView.isHidden = false
|
|
|
|
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true
|
|
|
|
statusView.playerContainerView.contentWarningOverlayView.isHidden = true
|
2021-05-08 08:35:23 +02:00
|
|
|
|
2021-06-23 14:47:49 +02:00
|
|
|
if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp {
|
|
|
|
statusView.updateRevealContentWarningButton(isRevealing: true)
|
|
|
|
statusView.updateContentWarningDisplay(isHidden: true, animated: animated)
|
|
|
|
attribute.isRevealing.value = true
|
|
|
|
} else {
|
|
|
|
statusView.updateRevealContentWarningButton(isRevealing: false)
|
|
|
|
statusView.updateContentWarningDisplay(isHidden: false, animated: animated)
|
|
|
|
attribute.isRevealing.value = false
|
|
|
|
}
|
|
|
|
case .media(let isSensitive):
|
|
|
|
if !isSensitive, documentStore.defaultRevealStatusDict[status.id] == nil {
|
|
|
|
documentStore.defaultRevealStatusDict[status.id] = true
|
|
|
|
}
|
|
|
|
statusView.revealContentWarningButton.isHidden = false
|
|
|
|
statusView.contentWarningOverlayView.isHidden = true
|
|
|
|
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = false
|
|
|
|
statusView.playerContainerView.contentWarningOverlayView.isHidden = false
|
|
|
|
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
|
|
|
|
|
|
|
func updateContentOverlay() {
|
|
|
|
let needsReveal: Bool = {
|
|
|
|
if documentStore.defaultRevealStatusDict[status.id] == true {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}()
|
|
|
|
attribute.isRevealing.value = needsReveal
|
|
|
|
if needsReveal {
|
|
|
|
statusView.updateRevealContentWarningButton(isRevealing: true)
|
|
|
|
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: true, style: .media)
|
|
|
|
statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: true, style: .media)
|
|
|
|
} else {
|
|
|
|
statusView.updateRevealContentWarningButton(isRevealing: false)
|
|
|
|
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: false, style: .media)
|
|
|
|
statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: false, style: .media)
|
2021-05-08 08:35:23 +02:00
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
}
|
|
|
|
if animated {
|
|
|
|
UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) {
|
|
|
|
updateContentOverlay()
|
|
|
|
} completion: { _ in
|
|
|
|
// do nothing
|
2021-02-23 12:18:34 +01:00
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
} else {
|
|
|
|
updateContentOverlay()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static func configureThreadMeta(
|
|
|
|
cell: StatusTableViewCell,
|
|
|
|
status: Status
|
|
|
|
) {
|
|
|
|
cell.selectionStyle = .none
|
|
|
|
cell.threadMetaView.dateLabel.text = {
|
|
|
|
let formatter = DateFormatter()
|
|
|
|
formatter.dateStyle = .medium
|
|
|
|
formatter.timeStyle = .short
|
|
|
|
return formatter.string(from: status.createdAt)
|
2021-02-23 12:18:34 +01:00
|
|
|
}()
|
2021-06-23 14:47:49 +02:00
|
|
|
cell.threadMetaView.dateLabel.accessibilityLabel = DateFormatter.localizedString(from: status.createdAt, dateStyle: .medium, timeStyle: .short)
|
|
|
|
let reblogCountTitle: String = {
|
|
|
|
let count = status.reblogsCount.intValue
|
|
|
|
if count > 1 {
|
|
|
|
return L10n.Scene.Thread.Reblog.multiple(String(count))
|
2021-04-16 14:06:36 +02:00
|
|
|
} else {
|
2021-06-23 14:47:49 +02:00
|
|
|
return L10n.Scene.Thread.Reblog.single(String(count))
|
2021-04-16 14:06:36 +02:00
|
|
|
}
|
|
|
|
}()
|
2021-06-23 14:47:49 +02:00
|
|
|
cell.threadMetaView.reblogButton.setTitle(reblogCountTitle, for: .normal)
|
|
|
|
|
|
|
|
let favoriteCountTitle: String = {
|
|
|
|
let count = status.favouritesCount.intValue
|
|
|
|
if count > 1 {
|
|
|
|
return L10n.Scene.Thread.Favorite.multiple(String(count))
|
|
|
|
} else {
|
|
|
|
return L10n.Scene.Thread.Favorite.single(String(count))
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
cell.threadMetaView.favoriteButton.setTitle(favoriteCountTitle, for: .normal)
|
|
|
|
|
|
|
|
cell.threadMetaView.isHidden = false
|
|
|
|
}
|
|
|
|
|
|
|
|
static func configureStatusViewHeader(
|
|
|
|
cell: StatusCell,
|
|
|
|
status: Status
|
|
|
|
) {
|
|
|
|
if status.reblog != nil {
|
|
|
|
cell.statusView.headerContainerView.isHidden = false
|
|
|
|
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
|
|
|
|
let headerText: String = {
|
|
|
|
let author = status.author
|
|
|
|
let name = author.displayName.isEmpty ? author.username : author.displayName
|
|
|
|
return L10n.Common.Controls.Status.userReblogged(name)
|
|
|
|
}()
|
|
|
|
MastodonStatusContent.parseResult(content: headerText, emojiDict: status.author.emojiDict)
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
.sink { [weak cell] parseResult in
|
|
|
|
guard let cell = cell else { return }
|
|
|
|
cell.statusView.headerInfoLabel.configure(contentParseResult: parseResult)
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
|
|
|
cell.statusView.headerInfoLabel.isAccessibilityElement = true
|
|
|
|
} else if status.inReplyToID != nil {
|
|
|
|
cell.statusView.headerContainerView.isHidden = false
|
|
|
|
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
|
|
|
let headerText: String = {
|
|
|
|
guard let replyTo = status.replyTo else {
|
|
|
|
return L10n.Common.Controls.Status.userRepliedTo("-")
|
|
|
|
}
|
|
|
|
let author = replyTo.author
|
|
|
|
let name = author.displayName.isEmpty ? author.username : author.displayName
|
|
|
|
return L10n.Common.Controls.Status.userRepliedTo(name)
|
|
|
|
}()
|
|
|
|
MastodonStatusContent.parseResult(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:])
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
.sink { [weak cell] parseResult in
|
|
|
|
guard let cell = cell else { return }
|
|
|
|
cell.statusView.headerInfoLabel.configure(contentParseResult: parseResult)
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
|
|
|
cell.statusView.headerInfoLabel.isAccessibilityElement = true
|
|
|
|
} else {
|
|
|
|
cell.statusView.headerContainerView.isHidden = true
|
|
|
|
cell.statusView.headerInfoLabel.isAccessibilityElement = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static func configureStatusViewAuthor(
|
|
|
|
cell: StatusCell,
|
|
|
|
status: Status
|
|
|
|
) {
|
|
|
|
// name
|
|
|
|
let author = (status.reblog ?? status).author
|
|
|
|
let nameContent = author.displayNameWithFallback
|
|
|
|
cell.statusView.nameLabel.configure(content: nameContent, emojiDict: author.emojiDict)
|
|
|
|
// username
|
|
|
|
cell.statusView.usernameLabel.text = "@" + author.acct
|
|
|
|
// avatar
|
|
|
|
if let reblog = status.reblog {
|
|
|
|
cell.statusView.avatarImageView.isHidden = true
|
|
|
|
cell.statusView.avatarStackedContainerButton.isHidden = false
|
|
|
|
cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL()))
|
|
|
|
cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
|
|
|
|
} else {
|
|
|
|
cell.statusView.avatarImageView.isHidden = false
|
|
|
|
cell.statusView.avatarStackedContainerButton.isHidden = true
|
|
|
|
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static func configureStatusContent(
|
|
|
|
cell: StatusCell,
|
|
|
|
status: Status,
|
|
|
|
readableLayoutFrame: CGRect?,
|
|
|
|
statusItemAttribute: Item.StatusAttribute
|
|
|
|
) {
|
|
|
|
// set content
|
|
|
|
cell.statusView.activeTextLabel.configure(
|
|
|
|
content: (status.reblog ?? status).content,
|
|
|
|
emojiDict: (status.reblog ?? status).emojiDict
|
|
|
|
)
|
|
|
|
cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language
|
|
|
|
|
|
|
|
// set visibility
|
|
|
|
if let visibility = (status.reblog ?? status).visibility {
|
|
|
|
cell.statusView.updateVisibility(visibility: visibility)
|
|
|
|
|
|
|
|
cell.statusView.revealContentWarningButton.publisher(for: \.isHidden)
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
.sink { [weak cell] isHidden in
|
|
|
|
cell?.statusView.visibilityImageView.isHidden = !isHidden
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
|
|
|
} else {
|
|
|
|
cell.statusView.visibilityImageView.isHidden = true
|
|
|
|
}
|
|
|
|
|
|
|
|
// prepare media attachments
|
|
|
|
let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
|
|
|
|
|
|
|
|
// set image
|
|
|
|
let mosaicImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments)
|
|
|
|
let imageViewMaxSize: CGSize = {
|
|
|
|
let maxWidth: CGFloat = {
|
|
|
|
// use timelinePostView width as container width
|
|
|
|
// that width follows readable width and keep constant width after rotate
|
|
|
|
let containerFrame = readableLayoutFrame ?? cell.statusView.frame
|
|
|
|
var containerWidth = containerFrame.width
|
|
|
|
containerWidth -= 10
|
|
|
|
containerWidth -= StatusView.avatarImageSize.width
|
|
|
|
return containerWidth
|
|
|
|
}()
|
|
|
|
let scale: CGFloat = {
|
|
|
|
switch mosaicImageViewModel.metas.count {
|
|
|
|
case 1: return 1.3
|
|
|
|
default: return 0.7
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
return CGSize(width: maxWidth, height: floor(maxWidth * scale))
|
|
|
|
}()
|
|
|
|
let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = {
|
|
|
|
if mosaicImageViewModel.metas.count == 1 {
|
|
|
|
let meta = mosaicImageViewModel.metas[0]
|
|
|
|
let mosaic = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
|
|
|
|
return [mosaic]
|
|
|
|
} else {
|
|
|
|
let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosaicImageViewModel.metas.count, maxSize: imageViewMaxSize)
|
|
|
|
return mosaics
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
for (i, mosaic) in mosaics.enumerated() {
|
|
|
|
let imageView = mosaic.imageView
|
|
|
|
let blurhashOverlayImageView = mosaic.blurhashOverlayImageView
|
|
|
|
let meta = mosaicImageViewModel.metas[i]
|
|
|
|
|
|
|
|
// set blurhash image
|
|
|
|
meta.blurhashImagePublisher()
|
|
|
|
.sink { image in
|
|
|
|
blurhashOverlayImageView.image = image
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
2021-06-16 13:58:28 +02:00
|
|
|
|
2021-06-17 07:45:16 +02:00
|
|
|
let isSingleMosaicLayout = mosaics.count == 1
|
2021-06-23 14:47:49 +02:00
|
|
|
|
2021-06-23 15:21:22 +02:00
|
|
|
// set link preview
|
|
|
|
cell.statusView.linkPreview.isHidden = true
|
|
|
|
|
|
|
|
var _firstURL: URL? = {
|
|
|
|
for entity in cell.statusView.activeTextLabel.activeEntities {
|
|
|
|
guard case let .url(_, _, url, _) = entity.type else { continue }
|
|
|
|
return URL(string: url)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}()
|
|
|
|
|
|
|
|
if let url = _firstURL {
|
|
|
|
Future<LPLinkMetadata?, Error> { promise in
|
|
|
|
LPMetadataProvider().startFetchingMetadata(for: url) { meta, error in
|
|
|
|
if let error = error {
|
|
|
|
promise(.failure(error))
|
|
|
|
} else {
|
|
|
|
promise(.success(meta))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.receive(on: RunLoop.main)
|
|
|
|
.sink { _ in
|
|
|
|
// do nothing
|
|
|
|
} receiveValue: { [weak cell] meta in
|
|
|
|
guard let meta = meta else { return }
|
|
|
|
guard let cell = cell else { return }
|
|
|
|
cell.statusView.linkPreview.metadata = meta
|
|
|
|
cell.statusView.linkPreview.isHidden = false
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
|
|
|
}
|
|
|
|
|
2021-06-17 07:45:16 +02:00
|
|
|
// set image
|
2021-06-16 13:58:28 +02:00
|
|
|
let imageSize = CGSize(
|
|
|
|
width: mosaic.imageViewSize.width * imageView.traitCollection.displayScale,
|
|
|
|
height: mosaic.imageViewSize.height * imageView.traitCollection.displayScale
|
|
|
|
)
|
|
|
|
let request = ImageRequest(
|
|
|
|
url: meta.url,
|
|
|
|
processors: [
|
2021-06-17 07:45:16 +02:00
|
|
|
ImageProcessors.Resize(
|
|
|
|
size: imageSize,
|
|
|
|
unit: .pixels,
|
|
|
|
contentMode: isSingleMosaicLayout ? .aspectFill : .aspectFit,
|
|
|
|
crop: isSingleMosaicLayout
|
|
|
|
)
|
2021-06-16 13:58:28 +02:00
|
|
|
]
|
|
|
|
)
|
|
|
|
let options = ImageLoadingOptions(
|
|
|
|
transition: .fadeIn(duration: 0.2)
|
|
|
|
)
|
2021-06-23 14:47:49 +02:00
|
|
|
|
2021-06-16 13:58:28 +02:00
|
|
|
Nuke.loadImage(
|
|
|
|
with: request,
|
|
|
|
options: options,
|
|
|
|
into: imageView
|
|
|
|
) { result in
|
|
|
|
switch result {
|
2021-04-16 14:06:36 +02:00
|
|
|
case .failure:
|
|
|
|
break
|
2021-06-17 08:03:30 +02:00
|
|
|
case .success:
|
2021-06-16 13:58:28 +02:00
|
|
|
statusItemAttribute.isImageLoaded.value = true
|
2021-04-16 14:06:36 +02:00
|
|
|
}
|
|
|
|
}
|
2021-06-17 07:45:16 +02:00
|
|
|
|
2021-05-12 12:26:53 +02:00
|
|
|
imageView.accessibilityLabel = meta.altText
|
2021-06-23 14:47:49 +02:00
|
|
|
|
|
|
|
// setup media content overlay trigger
|
2021-04-16 14:06:36 +02:00
|
|
|
Publishers.CombineLatest(
|
|
|
|
statusItemAttribute.isImageLoaded,
|
2021-04-19 12:33:11 +02:00
|
|
|
statusItemAttribute.isRevealing
|
2021-02-23 12:18:34 +01:00
|
|
|
)
|
2021-06-17 07:45:16 +02:00
|
|
|
.receive(on: DispatchQueue.main) // needs call immediately
|
2021-04-26 11:41:24 +02:00
|
|
|
.sink { [weak cell] isImageLoaded, isMediaRevealing in
|
2021-06-23 14:47:49 +02:00
|
|
|
guard let _ = cell else { return }
|
2021-04-16 14:06:36 +02:00
|
|
|
guard isImageLoaded else {
|
2021-06-23 14:47:49 +02:00
|
|
|
// always display blurhash image when before image loaded
|
2021-04-16 14:06:36 +02:00
|
|
|
blurhashOverlayImageView.alpha = 1
|
|
|
|
blurhashOverlayImageView.isHidden = false
|
|
|
|
return
|
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
|
|
|
|
// display blurhash image depends on revealing state
|
|
|
|
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
|
|
|
|
animator.addAnimations {
|
|
|
|
blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1
|
2021-04-16 14:06:36 +02:00
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
animator.startAnimation()
|
2021-02-23 12:18:34 +01:00
|
|
|
}
|
2021-04-16 14:06:36 +02:00
|
|
|
.store(in: &cell.disposeBag)
|
2021-02-23 12:18:34 +01:00
|
|
|
}
|
2021-06-16 12:32:48 +02:00
|
|
|
cell.statusView.statusMosaicImageViewContainer.isHidden = mosaicImageViewModel.metas.isEmpty
|
2021-06-23 14:47:49 +02:00
|
|
|
|
2021-03-08 04:42:10 +01:00
|
|
|
// set audio
|
|
|
|
if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first {
|
|
|
|
cell.statusView.audioView.isHidden = false
|
2021-06-23 14:47:49 +02:00
|
|
|
AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, audioService: AppContext.shared.audioPlaybackService)
|
2021-03-08 04:42:10 +01:00
|
|
|
} else {
|
|
|
|
cell.statusView.audioView.isHidden = true
|
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
|
2021-03-10 07:36:28 +01:00
|
|
|
// set GIF & video
|
|
|
|
let playerViewMaxSize: CGSize = {
|
|
|
|
let maxWidth: CGFloat = {
|
|
|
|
// use statusView width as container width
|
|
|
|
// that width follows readable width and keep constant width after rotate
|
|
|
|
let containerFrame = readableLayoutFrame ?? cell.statusView.frame
|
|
|
|
return containerFrame.width
|
|
|
|
}()
|
|
|
|
let scale: CGFloat = 1.3
|
2021-06-17 13:29:48 +02:00
|
|
|
return CGSize(width: maxWidth, height: floor(maxWidth * scale))
|
2021-03-10 07:36:28 +01:00
|
|
|
}()
|
2021-06-23 14:47:49 +02:00
|
|
|
|
2021-03-10 07:36:28 +01:00
|
|
|
if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
|
2021-06-23 14:47:49 +02:00
|
|
|
let videoPlayerViewModel = AppContext.shared.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) {
|
2021-04-19 12:06:02 +02:00
|
|
|
var parent: UIViewController?
|
|
|
|
var playerViewControllerDelegate: AVPlayerViewControllerDelegate? = nil
|
|
|
|
switch cell {
|
|
|
|
case is StatusTableViewCell:
|
|
|
|
let statusTableViewCell = cell as! StatusTableViewCell
|
|
|
|
parent = statusTableViewCell.delegate?.parent()
|
|
|
|
playerViewControllerDelegate = statusTableViewCell.delegate?.playerViewControllerDelegate
|
2021-04-19 12:16:28 +02:00
|
|
|
case is NotificationStatusTableViewCell:
|
|
|
|
let notificationTableViewCell = cell as! NotificationStatusTableViewCell
|
2021-04-19 12:06:02 +02:00
|
|
|
parent = notificationTableViewCell.delegate?.parent()
|
2021-04-26 09:58:49 +02:00
|
|
|
case is ReportedStatusTableViewCell:
|
|
|
|
let reportTableViewCell = cell as! ReportedStatusTableViewCell
|
|
|
|
parent = reportTableViewCell.dependency
|
2021-04-19 12:06:02 +02:00
|
|
|
default:
|
|
|
|
parent = nil
|
|
|
|
assertionFailure("unknown cell")
|
|
|
|
}
|
2021-03-11 12:23:44 +01:00
|
|
|
let playerContainerView = cell.statusView.playerContainerView
|
|
|
|
let playerViewController = playerContainerView.setupPlayer(
|
2021-03-10 07:36:28 +01:00
|
|
|
aspectRatio: videoPlayerViewModel.videoSize,
|
|
|
|
maxSize: playerViewMaxSize,
|
|
|
|
parent: parent
|
|
|
|
)
|
2021-04-19 12:06:02 +02:00
|
|
|
playerViewController.delegate = playerViewControllerDelegate
|
2021-03-10 07:36:28 +01:00
|
|
|
playerViewController.player = videoPlayerViewModel.player
|
|
|
|
playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
|
2021-03-12 08:41:57 +01:00
|
|
|
playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
|
2021-06-17 12:43:06 +02:00
|
|
|
switch videoPlayerViewModel.videoKind {
|
|
|
|
case .gif:
|
2021-03-15 10:53:06 +01:00
|
|
|
playerContainerView.setMediaIndicator(isHidden: false)
|
2021-06-17 12:43:06 +02:00
|
|
|
case .video:
|
|
|
|
playerContainerView.setMediaIndicator(isHidden: true)
|
2021-03-15 10:53:06 +01:00
|
|
|
}
|
2021-03-11 12:23:44 +01:00
|
|
|
playerContainerView.isHidden = false
|
2021-06-23 14:47:49 +02:00
|
|
|
|
2021-06-17 13:29:48 +02:00
|
|
|
// set blurhash overlay
|
|
|
|
playerContainerView.isReadyForDisplay
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
.sink { [weak playerContainerView] isReadyForDisplay in
|
|
|
|
guard let playerContainerView = playerContainerView else { return }
|
|
|
|
playerContainerView.blurhashOverlayImageView.alpha = isReadyForDisplay ? 0 : 1
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
2021-06-23 14:47:49 +02:00
|
|
|
|
2021-06-17 13:29:48 +02:00
|
|
|
if let blurhash = videoAttachment.blurhash,
|
|
|
|
let url = URL(string: videoAttachment.url) {
|
|
|
|
AppContext.shared.blurhashImageCacheService.image(
|
|
|
|
blurhash: blurhash,
|
|
|
|
size: playerContainerView.playerViewController.view.frame.size,
|
|
|
|
url: url
|
|
|
|
)
|
|
|
|
.sink { image in
|
|
|
|
playerContainerView.blurhashOverlayImageView.image = image
|
|
|
|
}
|
|
|
|
.store(in: &cell.disposeBag)
|
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
|
2021-03-10 07:36:28 +01:00
|
|
|
} else {
|
2021-03-11 12:06:15 +01:00
|
|
|
cell.statusView.playerContainerView.playerViewController.player?.pause()
|
|
|
|
cell.statusView.playerContainerView.playerViewController.player = nil
|
2021-03-10 07:36:28 +01:00
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
static func configurePoll(
|
|
|
|
cell: StatusCell,
|
|
|
|
poll: Poll?,
|
|
|
|
requestUserID: String,
|
|
|
|
updateProgressAnimated: Bool
|
|
|
|
) {
|
|
|
|
guard let poll = poll,
|
|
|
|
let managedObjectContext = poll.managedObjectContext
|
|
|
|
else {
|
|
|
|
cell.statusView.pollTableView.isHidden = true
|
|
|
|
cell.statusView.pollStatusStackView.isHidden = true
|
|
|
|
cell.statusView.pollVoteButton.isHidden = true
|
|
|
|
return
|
|
|
|
}
|
2021-04-16 14:06:36 +02:00
|
|
|
|
2021-06-23 14:47:49 +02:00
|
|
|
cell.statusView.pollTableView.isHidden = false
|
|
|
|
cell.statusView.pollStatusStackView.isHidden = false
|
|
|
|
cell.statusView.pollVoteCountLabel.text = {
|
|
|
|
if poll.multiple {
|
|
|
|
let count = poll.votersCount?.intValue ?? 0
|
|
|
|
if count > 1 {
|
|
|
|
return L10n.Common.Controls.Status.Poll.VoterCount.single(count)
|
|
|
|
} else {
|
|
|
|
return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count)
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
let count = poll.votesCount.intValue
|
|
|
|
if count > 1 {
|
|
|
|
return L10n.Common.Controls.Status.Poll.VoteCount.single(count)
|
|
|
|
} else {
|
|
|
|
return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count)
|
|
|
|
}
|
2021-04-16 14:06:36 +02:00
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
}()
|
|
|
|
if poll.expired {
|
|
|
|
cell.statusView.pollCountdownSubscription = nil
|
|
|
|
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed
|
|
|
|
} else if let expiresAt = poll.expiresAt {
|
|
|
|
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
|
|
|
|
cell.statusView.pollCountdownSubscription = AppContext.shared.timestampUpdatePublisher
|
2021-03-03 12:34:29 +01:00
|
|
|
.sink { _ in
|
2021-06-23 14:47:49 +02:00
|
|
|
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
|
2021-03-02 12:10:45 +01:00
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
} else {
|
|
|
|
cell.statusView.pollCountdownSubscription = nil
|
|
|
|
cell.statusView.pollCountdownLabel.text = "-"
|
2021-03-02 12:10:45 +01:00
|
|
|
}
|
2021-03-03 12:34:29 +01:00
|
|
|
|
2021-06-23 14:47:49 +02:00
|
|
|
cell.statusView.pollTableView.allowsSelection = !poll.expired
|
|
|
|
|
|
|
|
let votedOptions = poll.options.filter { option in
|
|
|
|
(option.votedBy ?? Set()).map(\.id).contains(requestUserID)
|
2021-04-19 12:06:02 +02:00
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
let didVotedLocal = !votedOptions.isEmpty
|
|
|
|
let didVotedRemote = (poll.votedBy ?? Set()).map(\.id).contains(requestUserID)
|
|
|
|
cell.statusView.pollVoteButton.isEnabled = didVotedLocal
|
|
|
|
cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired)
|
2021-04-13 13:46:42 +02:00
|
|
|
|
2021-06-23 14:47:49 +02:00
|
|
|
cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource(
|
|
|
|
for: cell.statusView.pollTableView,
|
|
|
|
managedObjectContext: managedObjectContext
|
|
|
|
)
|
2021-04-16 14:06:36 +02:00
|
|
|
|
2021-06-23 14:47:49 +02:00
|
|
|
var snapshot = NSDiffableDataSourceSnapshot<PollSection, PollItem>()
|
|
|
|
snapshot.appendSections([.main])
|
|
|
|
|
|
|
|
let pollItems = poll.options
|
|
|
|
.sorted(by: { $0.index.intValue < $1.index.intValue })
|
|
|
|
.map { option -> PollItem in
|
|
|
|
let attribute: PollItem.Attribute = {
|
|
|
|
let selectState: PollItem.Attribute.SelectState = {
|
|
|
|
// check didVotedRemote later to make the local change possible
|
|
|
|
if !votedOptions.isEmpty {
|
|
|
|
return votedOptions.contains(option) ? .on : .off
|
|
|
|
} else if poll.expired {
|
|
|
|
return .none
|
|
|
|
} else if didVotedRemote, votedOptions.isEmpty {
|
|
|
|
return .none
|
|
|
|
} else {
|
|
|
|
return .off
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
let voteState: PollItem.Attribute.VoteState = {
|
|
|
|
var needsReveal: Bool
|
|
|
|
if poll.expired {
|
|
|
|
needsReveal = true
|
|
|
|
} else if didVotedRemote {
|
|
|
|
needsReveal = true
|
|
|
|
} else {
|
|
|
|
needsReveal = false
|
|
|
|
}
|
|
|
|
guard needsReveal else { return .hidden }
|
|
|
|
let percentage: Double = {
|
|
|
|
guard poll.votesCount.intValue > 0 else { return 0.0 }
|
|
|
|
return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue)
|
|
|
|
}()
|
|
|
|
let voted = votedOptions.isEmpty ? true : votedOptions.contains(option)
|
|
|
|
return .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated)
|
|
|
|
}()
|
|
|
|
return PollItem.Attribute(selectState: selectState, voteState: voteState)
|
2021-04-16 14:06:36 +02:00
|
|
|
}()
|
2021-06-23 14:47:49 +02:00
|
|
|
let option = PollItem.opion(objectID: option.objectID, attribute: attribute)
|
|
|
|
return option
|
2021-04-13 13:46:42 +02:00
|
|
|
}
|
2021-06-23 14:47:49 +02:00
|
|
|
snapshot.appendItems(pollItems, toSection: .main)
|
|
|
|
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
2021-04-13 13:46:42 +02:00
|
|
|
}
|
2021-03-16 04:41:56 +01:00
|
|
|
|
2021-03-09 08:18:43 +01:00
|
|
|
static func configureActionToolBar(
|
2021-04-28 13:56:30 +02:00
|
|
|
cell: StatusTableViewCell,
|
2021-04-26 09:58:49 +02:00
|
|
|
dependency: NeedsDependency,
|
2021-04-01 08:39:15 +02:00
|
|
|
status: Status,
|
2021-03-09 08:18:43 +01:00
|
|
|
requestUserID: String
|
|
|
|
) {
|
2021-04-01 08:39:15 +02:00
|
|
|
let status = status.reblog ?? status
|
2021-06-23 14:47:49 +02:00
|
|
|
|
2021-03-09 08:18:43 +01:00
|
|
|
// set reply
|
|
|
|
let replyCountTitle: String = {
|
2021-04-01 08:39:15 +02:00
|
|
|
let count = status.repliesCount?.intValue ?? 0
|
2021-03-09 08:18:43 +01:00
|
|
|
return StatusSection.formattedNumberTitleForActionButton(count)
|
|
|
|
}()
|
|
|
|
cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal)
|
2021-05-12 12:26:53 +02:00
|
|
|
cell.statusView.actionToolbarContainer.replyButton.accessibilityValue = status.repliesCount.flatMap {
|
|
|
|
L10n.Common.Controls.Timeline.Accessibility.countReplies($0.intValue)
|
|
|
|
} ?? nil
|
2021-03-15 11:19:45 +01:00
|
|
|
// set reblog
|
2021-04-01 08:39:15 +02:00
|
|
|
let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
|
2021-03-15 11:19:45 +01:00
|
|
|
let reblogCountTitle: String = {
|
2021-04-01 08:39:15 +02:00
|
|
|
let count = status.reblogsCount.intValue
|
2021-03-09 08:18:43 +01:00
|
|
|
return StatusSection.formattedNumberTitleForActionButton(count)
|
|
|
|
}()
|
2021-03-15 11:19:45 +01:00
|
|
|
cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal)
|
|
|
|
cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged
|
2021-05-12 12:26:53 +02:00
|
|
|
cell.statusView.actionToolbarContainer.reblogButton.accessibilityLabel = isReblogged ? L10n.Common.Controls.Status.Actions.unreblog : L10n.Common.Controls.Status.Actions.reblog
|
|
|
|
cell.statusView.actionToolbarContainer.reblogButton.accessibilityValue = {
|
|
|
|
guard status.reblogsCount.intValue > 0 else { return nil }
|
|
|
|
return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue)
|
|
|
|
}()
|
2021-03-09 08:18:43 +01:00
|
|
|
// set like
|
2021-04-01 08:39:15 +02:00
|
|
|
let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
|
2021-03-09 08:18:43 +01:00
|
|
|
let favoriteCountTitle: String = {
|
2021-04-01 08:39:15 +02:00
|
|
|
let count = status.favouritesCount.intValue
|
2021-03-09 08:18:43 +01:00
|
|
|
return StatusSection.formattedNumberTitleForActionButton(count)
|
|
|
|
}()
|
|
|
|
cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal)
|
|
|
|
cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike
|
2021-05-12 12:26:53 +02:00
|
|
|
cell.statusView.actionToolbarContainer.favoriteButton.accessibilityLabel = isLike ? L10n.Common.Controls.Status.Actions.unfavorite : L10n.Common.Controls.Status.Actions.favorite
|
|
|
|
cell.statusView.actionToolbarContainer.favoriteButton.accessibilityValue = {
|
|
|
|
guard status.favouritesCount.intValue > 0 else { return nil }
|
|
|
|
return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.favouritesCount.intValue)
|
|
|
|
}()
|
2021-04-30 08:55:02 +02:00
|
|
|
Publishers.CombineLatest(
|
2021-06-21 10:38:59 +02:00
|
|
|
dependency.context.blockDomainService.blockedDomains.setFailureType(to: ManagedObjectObserver.Error.self),
|
2021-04-30 08:55:02 +02:00
|
|
|
ManagedObjectObserver.observe(object: status.authorForUserProvider)
|
2021-06-21 10:38:59 +02:00
|
|
|
)
|
2021-06-16 12:32:48 +02:00
|
|
|
.receive(on: RunLoop.main)
|
2021-06-21 10:38:59 +02:00
|
|
|
.sink(receiveCompletion: { _ in
|
|
|
|
// do nothing
|
|
|
|
}, receiveValue: { [weak dependency, weak cell] _, change in
|
2021-04-30 08:55:02 +02:00
|
|
|
guard let cell = cell else { return }
|
|
|
|
guard let dependency = dependency else { return }
|
2021-04-30 09:38:15 +02:00
|
|
|
switch change.changeType {
|
|
|
|
case .delete:
|
|
|
|
return
|
|
|
|
case .update(_):
|
|
|
|
break
|
|
|
|
case .none:
|
|
|
|
break
|
|
|
|
}
|
2021-05-06 12:03:58 +02:00
|
|
|
StatusSection.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
|
2021-06-21 10:38:59 +02:00
|
|
|
})
|
2021-04-30 08:55:02 +02:00
|
|
|
.store(in: &cell.disposeBag)
|
2021-05-06 12:03:58 +02:00
|
|
|
self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
|
2021-03-09 08:18:43 +01:00
|
|
|
}
|
2021-03-05 08:53:36 +01:00
|
|
|
|
2021-06-23 14:47:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
extension StatusSection {
|
2021-04-06 10:43:08 +02:00
|
|
|
static func configureEmptyStateHeader(
|
|
|
|
cell: TimelineHeaderTableViewCell,
|
|
|
|
attribute: Item.EmptyStateHeaderAttribute
|
|
|
|
) {
|
|
|
|
cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage
|
|
|
|
cell.timelineHeaderView.messageLabel.text = attribute.reason.message
|
|
|
|
}
|
2021-01-28 09:10:30 +01:00
|
|
|
}
|
|
|
|
|
2021-02-24 09:11:48 +01:00
|
|
|
extension StatusSection {
|
2021-01-28 09:10:30 +01:00
|
|
|
private static func formattedNumberTitleForActionButton(_ number: Int?) -> String {
|
|
|
|
guard let number = number, number > 0 else { return "" }
|
|
|
|
return String(number)
|
|
|
|
}
|
2021-04-26 09:58:49 +02:00
|
|
|
|
|
|
|
private static func setupStatusMoreButtonMenu(
|
2021-04-28 13:56:30 +02:00
|
|
|
cell: StatusTableViewCell,
|
2021-04-26 09:58:49 +02:00
|
|
|
dependency: NeedsDependency,
|
|
|
|
status: Status) {
|
|
|
|
|
2021-04-28 13:56:30 +02:00
|
|
|
guard let userProvider = dependency as? UserProvider else { fatalError() }
|
2021-04-26 09:58:49 +02:00
|
|
|
|
|
|
|
guard let authenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
|
|
|
return
|
|
|
|
}
|
2021-04-29 04:50:10 +02:00
|
|
|
let author = status.authorForUserProvider
|
2021-05-06 12:19:24 +02:00
|
|
|
let isMyself = authenticationBox.userID == author.id
|
2021-04-30 06:53:25 +02:00
|
|
|
let isInSameDomain = authenticationBox.domain == author.domainFromAcct
|
2021-04-28 13:56:30 +02:00
|
|
|
let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID)
|
|
|
|
let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID)
|
2021-04-30 08:55:02 +02:00
|
|
|
let isDomainBlocking = dependency.context.blockDomainService.blockedDomains.value.contains(author.domainFromAcct)
|
2021-04-26 09:58:49 +02:00
|
|
|
cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true
|
2021-04-30 08:55:02 +02:00
|
|
|
cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu(
|
|
|
|
for: author,
|
2021-05-06 12:19:24 +02:00
|
|
|
isMyself: isMyself,
|
2021-04-30 08:55:02 +02:00
|
|
|
isMuting: isMuting,
|
|
|
|
isBlocking: isBlocking,
|
|
|
|
isInSameDomain: isInSameDomain,
|
|
|
|
isDomainBlocking: isDomainBlocking,
|
|
|
|
provider: userProvider,
|
|
|
|
cell: cell,
|
|
|
|
sourceView: cell.statusView.actionToolbarContainer.moreButton,
|
|
|
|
barButtonItem: nil,
|
|
|
|
shareUser: nil,
|
|
|
|
shareStatus: status
|
|
|
|
)
|
2021-04-26 09:58:49 +02:00
|
|
|
}
|
2021-01-28 09:10:30 +01:00
|
|
|
}
|