forked from zelo72/mastodon-ios
chore: display status Content
This commit is contained in:
parent
42628398e6
commit
66b07f41db
|
@ -20,13 +20,19 @@ extension NotificationSection {
|
||||||
static func tableViewDiffableDataSource(
|
static func tableViewDiffableDataSource(
|
||||||
for tableView: UITableView,
|
for tableView: UITableView,
|
||||||
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
||||||
managedObjectContext: NSManagedObjectContext
|
managedObjectContext: NSManagedObjectContext,
|
||||||
|
delegate: NotificationTableViewCellDelegate,
|
||||||
|
dependency: NeedsDependency,
|
||||||
|
requestUserID: String
|
||||||
) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> {
|
) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> {
|
||||||
|
return UITableViewDiffableDataSource(tableView: tableView) {
|
||||||
return UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, notificationItem) -> UITableViewCell? in
|
[weak delegate,weak dependency]
|
||||||
|
(tableView, indexPath, notificationItem) -> UITableViewCell? in
|
||||||
|
guard let dependency = dependency else { return nil }
|
||||||
switch notificationItem {
|
switch notificationItem {
|
||||||
case .notification(let objectID):
|
case .notification(let objectID):
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell
|
||||||
|
cell.delegate = delegate
|
||||||
let notification = managedObjectContext.object(with: objectID) as! MastodonNotification
|
let notification = managedObjectContext.object(with: objectID) as! MastodonNotification
|
||||||
let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.type)
|
let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.type)
|
||||||
|
|
||||||
|
@ -69,7 +75,7 @@ extension NotificationSection {
|
||||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||||
cell.actionImageBackground.backgroundColor = color
|
cell.actionImageBackground.backgroundColor = color
|
||||||
cell.actionLabel.text = actionText + " · " + timeText
|
cell.actionLabel.text = actionText + " · " + timeText
|
||||||
cell.nameLabel.text = notification.account.displayName
|
cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName
|
||||||
cell.avatatImageView.af.setImage(
|
cell.avatatImageView.af.setImage(
|
||||||
withURL: URL(string: notification.account.avatar)!,
|
withURL: URL(string: notification.account.avatar)!,
|
||||||
placeholderImage: UIImage.placeholder(color: .systemFill),
|
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||||
|
@ -79,10 +85,18 @@ extension NotificationSection {
|
||||||
if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) {
|
if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) {
|
||||||
cell.actionImageView.image = actionImage
|
cell.actionImageView.image = actionImage
|
||||||
}
|
}
|
||||||
if let _ = notification.status {
|
if let status = notification.status {
|
||||||
cell.nameLabelLayoutIn(center: true)
|
let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - 61 - 2, height: tableView.readableContentGuide.layoutFrame.height)
|
||||||
} else {
|
NotificationSection.configure(cell: cell,
|
||||||
|
dependency: dependency,
|
||||||
|
readableLayoutFrame: frame,
|
||||||
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
|
status: status,
|
||||||
|
requestUserID: "",
|
||||||
|
statusItemAttribute: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false))
|
||||||
cell.nameLabelLayoutIn(center: false)
|
cell.nameLabelLayoutIn(center: false)
|
||||||
|
} else {
|
||||||
|
cell.nameLabelLayoutIn(center: true)
|
||||||
}
|
}
|
||||||
return cell
|
return cell
|
||||||
case .bottomLoader:
|
case .bottomLoader:
|
||||||
|
@ -93,3 +107,352 @@ extension NotificationSection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension NotificationSection {
|
||||||
|
static func configure(
|
||||||
|
cell: NotificationTableViewCell,
|
||||||
|
dependency: NeedsDependency,
|
||||||
|
readableLayoutFrame: CGRect?,
|
||||||
|
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
||||||
|
status: Status,
|
||||||
|
requestUserID: String,
|
||||||
|
statusItemAttribute: Item.StatusAttribute
|
||||||
|
) {
|
||||||
|
// disable interaction
|
||||||
|
cell.statusView.isUserInteractionEnabled = false
|
||||||
|
// remove actionToolBar
|
||||||
|
cell.statusView.actionToolbarContainer.removeFromSuperview()
|
||||||
|
// setup attribute
|
||||||
|
statusItemAttribute.setupForStatus(status: status)
|
||||||
|
|
||||||
|
// set header
|
||||||
|
NotificationSection.configureHeader(cell: cell, status: status)
|
||||||
|
ManagedObjectObserver.observe(object: status)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { _ in
|
||||||
|
// do nothing
|
||||||
|
} receiveValue: { change in
|
||||||
|
guard case .update(let object) = change.changeType,
|
||||||
|
let newStatus = object as? Status else { return }
|
||||||
|
NotificationSection.configureHeader(cell: cell, status: newStatus)
|
||||||
|
}
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
|
|
||||||
|
// set name username
|
||||||
|
cell.statusView.nameLabel.text = {
|
||||||
|
let author = (status.reblog ?? status).author
|
||||||
|
return author.displayName.isEmpty ? author.username : author.displayName
|
||||||
|
}()
|
||||||
|
cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
|
||||||
|
// set avatar
|
||||||
|
|
||||||
|
cell.statusView.avatarButton.isHidden = false
|
||||||
|
cell.statusView.avatarStackedContainerButton.isHidden = true
|
||||||
|
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
|
||||||
|
|
||||||
|
|
||||||
|
// set text
|
||||||
|
cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content)
|
||||||
|
|
||||||
|
// set status text content warning
|
||||||
|
let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive ?? false
|
||||||
|
let spoilerText = (status.reblog ?? status).spoilerText ?? ""
|
||||||
|
cell.statusView.isStatusTextSensitive = isStatusTextSensitive
|
||||||
|
cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive)
|
||||||
|
cell.statusView.contentWarningTitle.text = {
|
||||||
|
if spoilerText.isEmpty {
|
||||||
|
return L10n.Common.Controls.Status.statusContentWarning
|
||||||
|
} else {
|
||||||
|
return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// prepare media attachments
|
||||||
|
let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
|
||||||
|
|
||||||
|
// set image
|
||||||
|
let mosiacImageViewModel = 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 mosiacImageViewModel.metas.count {
|
||||||
|
case 1: return 1.3
|
||||||
|
default: return 0.7
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return CGSize(width: maxWidth, height: maxWidth * scale)
|
||||||
|
}()
|
||||||
|
if mosiacImageViewModel.metas.count == 1 {
|
||||||
|
let meta = mosiacImageViewModel.metas[0]
|
||||||
|
let imageView = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
|
||||||
|
imageView.af.setImage(
|
||||||
|
withURL: meta.url,
|
||||||
|
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||||
|
imageTransition: .crossDissolve(0.2)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let imageViews = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
|
||||||
|
for (i, imageView) in imageViews.enumerated() {
|
||||||
|
let meta = mosiacImageViewModel.metas[i]
|
||||||
|
imageView.af.setImage(
|
||||||
|
withURL: meta.url,
|
||||||
|
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||||
|
imageTransition: .crossDissolve(0.2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty
|
||||||
|
let isStatusSensitive = statusItemAttribute.isStatusSensitive ?? false
|
||||||
|
cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil
|
||||||
|
cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
|
||||||
|
cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive
|
||||||
|
|
||||||
|
// set audio
|
||||||
|
if let _ = mediaAttachments.filter({ $0.type == .audio }).first {
|
||||||
|
cell.statusView.audioView.isHidden = false
|
||||||
|
cell.statusView.audioView.playButton.isSelected = false
|
||||||
|
cell.statusView.audioView.slider.isEnabled = false
|
||||||
|
cell.statusView.audioView.slider.setValue(0, animated: false)
|
||||||
|
} else {
|
||||||
|
cell.statusView.audioView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
return CGSize(width: maxWidth, height: maxWidth * scale)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil
|
||||||
|
cell.statusView.playerContainerView.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
|
||||||
|
cell.statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive
|
||||||
|
|
||||||
|
if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
|
||||||
|
let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment)
|
||||||
|
{
|
||||||
|
let parent = cell.delegate?.parent()
|
||||||
|
let playerContainerView = cell.statusView.playerContainerView
|
||||||
|
let playerViewController = playerContainerView.setupPlayer(
|
||||||
|
aspectRatio: videoPlayerViewModel.videoSize,
|
||||||
|
maxSize: playerViewMaxSize,
|
||||||
|
parent: parent
|
||||||
|
)
|
||||||
|
playerViewController.player = videoPlayerViewModel.player
|
||||||
|
playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
|
||||||
|
playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
|
||||||
|
if videoPlayerViewModel.videoKind == .gif {
|
||||||
|
playerContainerView.setMediaIndicator(isHidden: false)
|
||||||
|
} else {
|
||||||
|
videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in
|
||||||
|
UIView.animate(withDuration: 0.33) {
|
||||||
|
switch timeControlStatus {
|
||||||
|
case .playing:
|
||||||
|
playerContainerView.setMediaIndicator(isHidden: true)
|
||||||
|
case .paused, .waitingToPlayAtSpecifiedRate:
|
||||||
|
playerContainerView.setMediaIndicator(isHidden: false)
|
||||||
|
@unknown default:
|
||||||
|
assertionFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
|
}
|
||||||
|
playerContainerView.isHidden = false
|
||||||
|
|
||||||
|
} else {
|
||||||
|
cell.statusView.playerContainerView.playerViewController.player?.pause()
|
||||||
|
cell.statusView.playerContainerView.playerViewController.player = nil
|
||||||
|
}
|
||||||
|
// set poll
|
||||||
|
let poll = (status.reblog ?? status).poll
|
||||||
|
NotificationSection.configurePoll(
|
||||||
|
cell: cell,
|
||||||
|
poll: poll,
|
||||||
|
requestUserID: requestUserID,
|
||||||
|
updateProgressAnimated: false,
|
||||||
|
timestampUpdatePublisher: timestampUpdatePublisher
|
||||||
|
)
|
||||||
|
if let poll = poll {
|
||||||
|
ManagedObjectObserver.observe(object: poll)
|
||||||
|
.sink { _ in
|
||||||
|
// do nothing
|
||||||
|
} receiveValue: { change in
|
||||||
|
guard case .update(let object) = change.changeType,
|
||||||
|
let newPoll = object as? Poll else { return }
|
||||||
|
NotificationSection.configurePoll(
|
||||||
|
cell: cell,
|
||||||
|
poll: newPoll,
|
||||||
|
requestUserID: requestUserID,
|
||||||
|
updateProgressAnimated: true,
|
||||||
|
timestampUpdatePublisher: timestampUpdatePublisher
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set date
|
||||||
|
let createdAt = (status.reblog ?? status).createdAt
|
||||||
|
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
||||||
|
timestampUpdatePublisher
|
||||||
|
.sink { _ in
|
||||||
|
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
||||||
|
}
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
static func configureHeader(
|
||||||
|
cell: NotificationTableViewCell,
|
||||||
|
status: Status
|
||||||
|
) {
|
||||||
|
if status.reblog != nil {
|
||||||
|
cell.statusView.headerContainerStackView.isHidden = false
|
||||||
|
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage)
|
||||||
|
cell.statusView.headerInfoLabel.text = {
|
||||||
|
let author = status.author
|
||||||
|
let name = author.displayName.isEmpty ? author.username : author.displayName
|
||||||
|
return L10n.Common.Controls.Status.userReblogged(name)
|
||||||
|
}()
|
||||||
|
} else if let replyTo = status.replyTo {
|
||||||
|
cell.statusView.headerContainerStackView.isHidden = false
|
||||||
|
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
||||||
|
cell.statusView.headerInfoLabel.text = {
|
||||||
|
let author = replyTo.author
|
||||||
|
let name = author.displayName.isEmpty ? author.username : author.displayName
|
||||||
|
return L10n.Common.Controls.Status.userRepliedTo(name)
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
cell.statusView.headerContainerStackView.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func configurePoll(
|
||||||
|
cell: NotificationTableViewCell,
|
||||||
|
poll: Poll?,
|
||||||
|
requestUserID: String,
|
||||||
|
updateProgressAnimated: Bool,
|
||||||
|
timestampUpdatePublisher: AnyPublisher<Date, Never>
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed
|
||||||
|
|
||||||
|
|
||||||
|
cell.statusView.pollTableView.allowsSelection = !poll.expired
|
||||||
|
|
||||||
|
let votedOptions = poll.options.filter { option in
|
||||||
|
(option.votedBy ?? Set()).map(\.id).contains(requestUserID)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource(
|
||||||
|
for: cell.statusView.pollTableView,
|
||||||
|
managedObjectContext: managedObjectContext
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}()
|
||||||
|
let option = PollItem.opion(objectID: option.objectID, attribute: attribute)
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
snapshot.appendItems(pollItems, toSection: .main)
|
||||||
|
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func configureEmptyStateHeader(
|
||||||
|
cell: TimelineHeaderTableViewCell,
|
||||||
|
attribute: Item.EmptyStateHeaderAttribute
|
||||||
|
) {
|
||||||
|
cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage
|
||||||
|
cell.timelineHeaderView.messageLabel.text = attribute.reason.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationSection {
|
||||||
|
private static func formattedNumberTitleForActionButton(_ number: Int?) -> String {
|
||||||
|
guard let number = number, number > 0 else { return "" }
|
||||||
|
return String(number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -53,6 +53,7 @@ internal enum Asset {
|
||||||
internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background")
|
internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background")
|
||||||
}
|
}
|
||||||
internal enum Border {
|
internal enum Border {
|
||||||
|
internal static let notification = ColorAsset(name: "Colors/Border/notification")
|
||||||
internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard")
|
internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard")
|
||||||
}
|
}
|
||||||
internal enum Button {
|
internal enum Button {
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xE8",
|
||||||
|
"green" : "0xE1",
|
||||||
|
"red" : "0xD9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "60",
|
||||||
|
"green" : "58",
|
||||||
|
"red" : "58"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,8 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
final class NotificationViewController: UIViewController, NeedsDependency {
|
final class NotificationViewController: UIViewController, NeedsDependency {
|
||||||
|
|
||||||
|
@ -58,7 +60,7 @@ extension NotificationViewController {
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
viewModel.tableView = tableView
|
viewModel.tableView = tableView
|
||||||
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
||||||
viewModel.setupDiffableDataSource(for: tableView)
|
viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self)
|
||||||
viewModel.viewDidLoad.send()
|
viewModel.viewDidLoad.send()
|
||||||
// bind refresh control
|
// bind refresh control
|
||||||
viewModel.isFetchingLatestNotification
|
viewModel.isFetchingLatestNotification
|
||||||
|
@ -125,10 +127,6 @@ extension NotificationViewController: UITableViewDelegate {
|
||||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
return 68
|
return 68
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
||||||
68
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||||
|
@ -138,6 +136,12 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NotificationViewController: NotificationTableViewCellDelegate {
|
||||||
|
func parent() -> UIViewController {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//// MARK: - UIScrollViewDelegate
|
//// MARK: - UIScrollViewDelegate
|
||||||
//extension NotificationViewController {
|
//extension NotificationViewController {
|
||||||
// func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
// func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
|
|
@ -13,17 +13,24 @@ import CoreDataStack
|
||||||
extension NotificationViewModel {
|
extension NotificationViewModel {
|
||||||
|
|
||||||
func setupDiffableDataSource(
|
func setupDiffableDataSource(
|
||||||
for tableView: UITableView
|
for tableView: UITableView,
|
||||||
|
delegate: NotificationTableViewCellDelegate,
|
||||||
|
dependency: NeedsDependency
|
||||||
) {
|
) {
|
||||||
let timestampUpdatePublisher = Timer.publish(every: 30.0, on: .main, in: .common)
|
let timestampUpdatePublisher = Timer.publish(every: 30.0, on: .main, in: .common)
|
||||||
.autoconnect()
|
.autoconnect()
|
||||||
.share()
|
.share()
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
guard let userid = activeMastodonAuthenticationBox.value?.userID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
diffableDataSource = NotificationSection.tableViewDiffableDataSource(
|
diffableDataSource = NotificationSection.tableViewDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
managedObjectContext: context.managedObjectContext
|
managedObjectContext: context.managedObjectContext,
|
||||||
|
delegate: delegate,
|
||||||
|
dependency: dependency,
|
||||||
|
requestUserID: userid
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -85,7 +85,12 @@ final class NotificationViewModel: NSObject {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.fetchedResultsController.fetchRequest.predicate = predicate
|
self.fetchedResultsController.fetchRequest.predicate = predicate
|
||||||
do {
|
do {
|
||||||
|
self.diffableDataSource?.defaultRowAnimation = .fade
|
||||||
try self.fetchedResultsController.performFetch()
|
try self.fetchedResultsController.performFetch()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.diffableDataSource?.defaultRowAnimation = .automatic
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
assertionFailure(error.localizedDescription)
|
assertionFailure(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,17 +5,23 @@
|
||||||
// Created by sxiaojian on 2021/4/13.
|
// Created by sxiaojian on 2021/4/13.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
|
||||||
|
|
||||||
|
protocol NotificationTableViewCellDelegate: class {
|
||||||
|
var context: AppContext! { get }
|
||||||
|
|
||||||
|
func parent() -> UIViewController
|
||||||
|
}
|
||||||
|
|
||||||
final class NotificationTableViewCell: UITableViewCell {
|
final class NotificationTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
static let actionImageBorderWidth: CGFloat = 2
|
static let actionImageBorderWidth: CGFloat = 2
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
var delegate: NotificationTableViewCellDelegate?
|
||||||
|
|
||||||
let avatatImageView: UIImageView = {
|
let avatatImageView: UIImageView = {
|
||||||
let imageView = UIImageView()
|
let imageView = UIImageView()
|
||||||
imageView.layer.cornerRadius = 4
|
imageView.layer.cornerRadius = 4
|
||||||
|
@ -32,7 +38,7 @@ final class NotificationTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
let actionImageBackground: UIView = {
|
let actionImageBackground: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.layer.cornerRadius = (24 + NotificationTableViewCell.actionImageBorderWidth)/2
|
view.layer.cornerRadius = (24 + NotificationTableViewCell.actionImageBorderWidth) / 2
|
||||||
view.layer.cornerCurve = .continuous
|
view.layer.cornerCurve = .continuous
|
||||||
view.clipsToBounds = true
|
view.clipsToBounds = true
|
||||||
view.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth
|
view.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth
|
||||||
|
@ -58,11 +64,30 @@ final class NotificationTableViewCell: UITableViewCell {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var nameLabelTop: NSLayoutConstraint!
|
var nameLabelTop: NSLayoutConstraint!
|
||||||
|
var nameLabelBottom: NSLayoutConstraint!
|
||||||
|
|
||||||
|
let statusContainer: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.layer.cornerRadius = 6
|
||||||
|
view.layer.borderWidth = 2
|
||||||
|
view.layer.cornerCurve = .continuous
|
||||||
|
view.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
|
||||||
|
view.clipsToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
let statusView = StatusView()
|
||||||
|
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
avatatImageView.af.cancelImageRequest()
|
avatatImageView.af.cancelImageRequest()
|
||||||
|
statusView.isStatusTextSensitive = false
|
||||||
|
statusView.cleanUpContentWarning()
|
||||||
|
statusView.pollTableView.dataSource = nil
|
||||||
|
statusView.playerContainerView.reset()
|
||||||
|
statusView.playerContainerView.isHidden = true
|
||||||
|
disposeBag.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
@ -74,11 +99,19 @@ final class NotificationTableViewCell: UITableViewCell {
|
||||||
super.init(coder: coder)
|
super.init(coder: coder)
|
||||||
configure()
|
configure()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.statusView.drawContentWarningImageView()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NotificationTableViewCell {
|
extension NotificationTableViewCell {
|
||||||
|
|
||||||
func configure() {
|
func configure() {
|
||||||
|
selectionStyle = .none
|
||||||
|
|
||||||
contentView.addSubview(avatatImageView)
|
contentView.addSubview(avatatImageView)
|
||||||
avatatImageView.pin(toSize: CGSize(width: 35, height: 35))
|
avatatImageView.pin(toSize: CGSize(width: 35, height: 35))
|
||||||
avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil)
|
avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil)
|
||||||
|
@ -90,7 +123,8 @@ extension NotificationTableViewCell {
|
||||||
actionImageBackground.addSubview(actionImageView)
|
actionImageBackground.addSubview(actionImageView)
|
||||||
actionImageView.constrainToCenter()
|
actionImageView.constrainToCenter()
|
||||||
|
|
||||||
nameLabelTop = nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor)
|
nameLabelTop = nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24)
|
||||||
|
nameLabelBottom = contentView.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 24)
|
||||||
contentView.addSubview(nameLabel)
|
contentView.addSubview(nameLabel)
|
||||||
nameLabel.constrain([
|
nameLabel.constrain([
|
||||||
nameLabelTop,
|
nameLabelTop,
|
||||||
|
@ -100,21 +134,38 @@ extension NotificationTableViewCell {
|
||||||
contentView.addSubview(actionLabel)
|
contentView.addSubview(actionLabel)
|
||||||
actionLabel.constrain([
|
actionLabel.constrain([
|
||||||
actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4),
|
actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4),
|
||||||
actionLabel.topAnchor.constraint(equalTo: nameLabel.topAnchor),
|
actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor),
|
||||||
contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4)
|
contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func nameLabelLayoutIn(center: Bool) {
|
public func nameLabelLayoutIn(center: Bool) {
|
||||||
if center {
|
if center {
|
||||||
nameLabelTop.constant = 24
|
nameLabelTop.constant = 24
|
||||||
|
NSLayoutConstraint.activate([nameLabelBottom])
|
||||||
|
statusView.removeFromSuperview()
|
||||||
|
statusContainer.removeFromSuperview()
|
||||||
} else {
|
} else {
|
||||||
nameLabelTop.constant = 12
|
nameLabelTop.constant = 12
|
||||||
|
NSLayoutConstraint.deactivate([nameLabelBottom])
|
||||||
|
addStatusAndContainer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addStatusAndContainer() {
|
||||||
|
contentView.addSubview(statusContainer)
|
||||||
|
statusContainer.pin(top: 40, left: 63, bottom: 14, right: 14)
|
||||||
|
|
||||||
|
contentView.addSubview(statusView)
|
||||||
|
statusView.pin(top: 40 + 12, left: 63 + 12, bottom: 14 + 12, right: 14 + 12)
|
||||||
|
}
|
||||||
|
|
||||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
super.traitCollectionDidChange(previousTraitCollection)
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
statusContainer.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
|
||||||
self.actionImageBackground.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor
|
actionImageBackground.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue