Notifications tab

This commit is contained in:
Justin Mazzocchi 2020-10-30 17:29:43 -07:00
parent 00605ff212
commit 79b1c531f0
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
7 changed files with 266 additions and 106 deletions

View file

@ -17,6 +17,7 @@
D036AA02254B6101009094DF /* NotificationListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA01254B6101009094DF /* NotificationListCell.swift */; };
D036AA07254B6118009094DF /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA06254B6118009094DF /* NotificationView.swift */; };
D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */; };
D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA16254CA823009094DF /* StatusBodyView.swift */; };
D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03B1B29253818F3008F964B /* MediaPreferencesView.swift */; };
D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */; };
D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; };
@ -124,6 +125,7 @@
D036AA01254B6101009094DF /* NotificationListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListCell.swift; sourceTree = "<group>"; };
D036AA06254B6118009094DF /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = "<group>"; };
D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentConfiguration.swift; sourceTree = "<group>"; };
D036AA16254CA823009094DF /* StatusBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBodyView.swift; sourceTree = "<group>"; };
D03B1B29253818F3008F964B /* MediaPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreferencesView.swift; sourceTree = "<group>"; };
D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAndSyncingPreferencesView.swift; sourceTree = "<group>"; };
D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -278,6 +280,7 @@
D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */,
D0BEB1F224F8EE8C001B0F04 /* StatusAttachmentView.swift */,
D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */,
D036AA16254CA823009094DF /* StatusBodyView.swift */,
D0625E58250F092900502611 /* StatusListCell.swift */,
D00CB2EC2533ACC00080096B /* StatusView.swift */,
);
@ -645,6 +648,7 @@
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */,
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */,
D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */,
D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */,

View file

@ -391,7 +391,7 @@ private extension TableViewController {
view.isDescendant(of: visibleView),
let superview = view.superview,
let attachmentsViewClosestToCenter = tableView.visibleCells
.compactMap({ ($0.contentView as? StatusView)?.attachmentsView })
.compactMap({ ($0.contentView as? StatusView)?.bodyView.attachmentsView })
.filter(\.shouldAutoplay)
.min(by: {
abs(superview.convert($0.frame, from: $0.superview).midY - view.frame.midY)

View file

@ -165,6 +165,7 @@ extension CollectionItemsViewModel: CollectionViewModel {
}
}
// swiftlint:disable:next function_body_length
public func viewModel(indexPath: IndexPath) -> CollectionItemViewModel {
let item = items.value[indexPath.section][indexPath.item]
let cachedViewModel = viewModelCache[item]?.viewModel

View file

@ -7,6 +7,7 @@ import ServiceLayer
public final class NotificationViewModel: CollectionItemViewModel, ObservableObject {
public let accountViewModel: AccountViewModel
public let statusViewModel: StatusViewModel?
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
private let notificationService: NotificationService
@ -20,6 +21,15 @@ public final class NotificationViewModel: CollectionItemViewModel, ObservableObj
accountService: notificationService.navigationService.accountService(
account: notificationService.notification.account),
identification: identification)
if let status = notificationService.notification.status {
statusViewModel = StatusViewModel(
statusService: notificationService.navigationService.statusService(status: status),
identification: identification)
} else {
statusViewModel = nil
}
self.events = eventsSubject.eraseToAnyPublisher()
}
}
@ -28,4 +38,14 @@ public extension NotificationViewModel {
var type: MastodonNotification.NotificationType {
notificationService.notification.type
}
func accountSelected() {
eventsSubject.send(
Just(.navigation(
.profile(
notificationService.navigationService.profileService(
account: notificationService.notification.account))))
.setFailureType(to: Error.self)
.eraseToAnyPublisher())
}
}

View file

@ -1,11 +1,17 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Kingfisher
import Mastodon
import UIKit
class NotificationView: UIView {
final class NotificationView: UIView {
private let iconImageView = UIImageView()
private let avatarImageView = AnimatedImageView()
private let avatarButton = UIButton()
private let typeLabel = UILabel()
private let displayNameLabel = UILabel()
private let accountLabel = UILabel()
private let statusBodyView = StatusBodyView()
private var notificationConfiguration: NotificationContentConfiguration
init(configuration: NotificationContentConfiguration) {
@ -37,31 +43,89 @@ extension NotificationView: UIContentView {
}
private extension NotificationView {
// swiftlint:disable function_body_length
func initialSetup() {
let stackView = UIStackView()
let containerStackView = UIStackView()
let sideStackView = UIStackView()
let mainStackView = UIStackView()
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.spacing = .compactSpacing
addSubview(containerStackView)
containerStackView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.spacing = .defaultSpacing
stackView.addArrangedSubview(iconImageView)
sideStackView.axis = .vertical
sideStackView.alignment = .trailing
sideStackView.spacing = .compactSpacing
sideStackView.addArrangedSubview(iconImageView)
sideStackView.addArrangedSubview(avatarImageView)
sideStackView.addArrangedSubview(UIView())
containerStackView.addArrangedSubview(sideStackView)
mainStackView.axis = .vertical
mainStackView.spacing = .compactSpacing
mainStackView.addArrangedSubview(typeLabel)
mainStackView.addSubview(UIView())
mainStackView.addArrangedSubview(statusBodyView)
mainStackView.addArrangedSubview(displayNameLabel)
mainStackView.addArrangedSubview(accountLabel)
containerStackView.addArrangedSubview(mainStackView)
iconImageView.contentMode = .scaleAspectFit
iconImageView.setContentHuggingPriority(.required, for: .horizontal)
stackView.addArrangedSubview(typeLabel)
typeLabel.font = .preferredFont(forTextStyle: .body)
avatarImageView.layer.cornerRadius = .avatarDimension / 2
avatarImageView.clipsToBounds = true
let avatarHeightConstraint = avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension)
avatarHeightConstraint.priority = .justBelowMax
avatarButton.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.addSubview(avatarButton)
avatarImageView.isUserInteractionEnabled = true
avatarButton.setBackgroundImage(.highlightedButtonBackground, for: .highlighted)
avatarButton.addAction(
UIAction { [weak self] _ in self?.notificationConfiguration.viewModel.accountSelected() },
for: .touchUpInside)
typeLabel.font = .preferredFont(forTextStyle: .headline)
typeLabel.adjustsFontForContentSizeCategory = true
typeLabel.numberOfLines = 0
statusBodyView.alpha = 0.5
statusBodyView.isUserInteractionEnabled = false
displayNameLabel.font = .preferredFont(forTextStyle: .headline)
displayNameLabel.adjustsFontForContentSizeCategory = true
displayNameLabel.numberOfLines = 0
accountLabel.font = .preferredFont(forTextStyle: .subheadline)
accountLabel.adjustsFontForContentSizeCategory = true
accountLabel.textColor = .secondaryLabel
accountLabel.numberOfLines = 0
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor)
containerStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
containerStackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
containerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor),
avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension),
avatarHeightConstraint,
sideStackView.widthAnchor.constraint(equalToConstant: .avatarDimension),
iconImageView.centerYAnchor.constraint(equalTo: typeLabel.centerYAnchor),
avatarButton.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor),
avatarButton.topAnchor.constraint(equalTo: avatarImageView.topAnchor),
avatarButton.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
avatarButton.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor)
])
}
func applyNotificationConfiguration() {
let viewModel = notificationConfiguration.viewModel
avatarImageView.kf.setImage(with: viewModel.accountViewModel.avatarURL())
switch viewModel.type {
case .follow:
typeLabel.attributedText = "notifications.followed-you".localizedBolding(
@ -96,10 +160,28 @@ private extension NotificationView {
iconImageView.tintColor = nil
}
if viewModel.statusViewModel == nil {
let mutableDisplayName = NSMutableAttributedString(string: viewModel.accountViewModel.displayName)
mutableDisplayName.insert(emoji: viewModel.accountViewModel.emoji, view: displayNameLabel)
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
displayNameLabel.attributedText = mutableDisplayName
accountLabel.text = viewModel.accountViewModel.accountName
statusBodyView.isHidden = true
displayNameLabel.isHidden = false
accountLabel.isHidden = false
} else {
statusBodyView.viewModel = viewModel.statusViewModel
statusBodyView.isHidden = false
displayNameLabel.isHidden = true
accountLabel.isHidden = true
}
iconImageView.image = UIImage(
systemName: viewModel.type.systemImageName,
withConfiguration: UIImage.SymbolConfiguration(scale: .medium))
}
// swiftlint:enable function_body_length
}
extension MastodonNotification.NotificationType {

View file

@ -0,0 +1,138 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
final class StatusBodyView: UIView {
let spoilerTextLabel = UILabel()
let toggleShowContentButton = UIButton(type: .system)
let contentTextView = TouchFallthroughTextView()
let attachmentsView = StatusAttachmentsView()
let pollView = PollView()
let cardView = CardView()
var viewModel: StatusViewModel? {
didSet {
guard let viewModel = viewModel else { return }
let isContextParent = viewModel.configuration.isContextParent
let mutableContent = NSMutableAttributedString(attributedString: viewModel.content)
let mutableSpoilerText = NSMutableAttributedString(string: viewModel.spoilerText)
let contentFont = UIFont.preferredFont(forTextStyle: isContextParent ? .title3 : .callout)
let contentRange = NSRange(location: 0, length: mutableContent.length)
contentTextView.shouldFallthrough = !isContextParent
mutableContent.removeAttribute(.font, range: contentRange)
mutableContent.addAttributes(
[.font: contentFont, .foregroundColor: UIColor.label],
range: contentRange)
mutableContent.insert(emoji: viewModel.contentEmoji, view: contentTextView)
mutableContent.resizeAttachments(toLineHeight: contentFont.lineHeight)
contentTextView.attributedText = mutableContent
contentTextView.isHidden = contentTextView.text == ""
mutableSpoilerText.insert(emoji: viewModel.contentEmoji, view: spoilerTextLabel)
mutableSpoilerText.resizeAttachments(toLineHeight: spoilerTextLabel.font.lineHeight)
spoilerTextLabel.font = contentFont
spoilerTextLabel.attributedText = mutableSpoilerText
spoilerTextLabel.isHidden = spoilerTextLabel.text == ""
toggleShowContentButton.setTitle(
viewModel.shouldShowContent
? NSLocalizedString("status.show-less", comment: "")
: NSLocalizedString("status.show-more", comment: ""),
for: .normal)
toggleShowContentButton.isHidden = viewModel.spoilerText == ""
contentTextView.isHidden = !viewModel.shouldShowContent
attachmentsView.isHidden = viewModel.attachmentViewModels.count == 0
attachmentsView.viewModel = viewModel
pollView.isHidden = viewModel.pollOptions.count == 0
pollView.viewModel = viewModel
cardView.viewModel = viewModel.cardViewModel
cardView.isHidden = viewModel.cardViewModel == nil
}
}
override init(frame: CGRect) {
super.init(frame: frame)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension StatusBodyView: UITextViewDelegate {
func textView(
_ textView: UITextView,
shouldInteractWith URL: URL,
in characterRange: NSRange,
interaction: UITextItemInteraction) -> Bool {
switch interaction {
case .invokeDefaultAction:
viewModel?.urlSelected(URL)
return false
case .preview: return false
case .presentActions: return false
@unknown default: return false
}
}
}
private extension StatusBodyView {
func initialSetup() {
let stackView = UIStackView()
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = .compactSpacing
spoilerTextLabel.numberOfLines = 0
spoilerTextLabel.adjustsFontForContentSizeCategory = true
stackView.addArrangedSubview(spoilerTextLabel)
toggleShowContentButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
toggleShowContentButton.titleLabel?.adjustsFontForContentSizeCategory = true
toggleShowContentButton.addAction(
UIAction { [weak self] _ in self?.viewModel?.toggleShowContent() },
for: .touchUpInside)
stackView.addArrangedSubview(toggleShowContentButton)
contentTextView.adjustsFontForContentSizeCategory = true
contentTextView.isScrollEnabled = false
contentTextView.backgroundColor = .clear
contentTextView.delegate = self
stackView.addArrangedSubview(contentTextView)
stackView.addArrangedSubview(attachmentsView)
stackView.addArrangedSubview(pollView)
cardView.button.addAction(
UIAction { [weak self] _ in
guard
let viewModel = self?.viewModel,
let url = viewModel.cardViewModel?.url
else { return }
viewModel.urlSelected(url)
},
for: .touchUpInside)
stackView.addArrangedSubview(cardView)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}

View file

@ -12,12 +12,7 @@ final class StatusView: UIView {
let displayNameLabel = UILabel()
let accountLabel = UILabel()
let timeLabel = UILabel()
let spoilerTextLabel = UILabel()
let toggleShowContentButton = UIButton(type: .system)
let contentTextView = TouchFallthroughTextView()
let attachmentsView = StatusAttachmentsView()
let pollView = PollView()
let cardView = CardView()
let bodyView = StatusBodyView()
let contextParentTimeLabel = UILabel()
let timeApplicationDividerLabel = UILabel()
let applicationButton = UIButton(type: .system)
@ -145,38 +140,7 @@ private extension StatusView {
nameAccountContainerStackView.addArrangedSubview(nameAccountTimeStackView)
mainStackView.addArrangedSubview(nameAccountContainerStackView)
spoilerTextLabel.numberOfLines = 0
spoilerTextLabel.adjustsFontForContentSizeCategory = true
mainStackView.addArrangedSubview(spoilerTextLabel)
toggleShowContentButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
toggleShowContentButton.titleLabel?.adjustsFontForContentSizeCategory = true
toggleShowContentButton.addAction(
UIAction { [weak self] _ in self?.statusConfiguration.viewModel.toggleShowContent() },
for: .touchUpInside)
mainStackView.addArrangedSubview(toggleShowContentButton)
contentTextView.adjustsFontForContentSizeCategory = true
contentTextView.isScrollEnabled = false
contentTextView.backgroundColor = .clear
contentTextView.delegate = self
mainStackView.addArrangedSubview(contentTextView)
mainStackView.addArrangedSubview(attachmentsView)
mainStackView.addArrangedSubview(pollView)
cardView.button.addAction(
UIAction { [weak self] _ in
guard
let viewModel = self?.statusConfiguration.viewModel,
let url = viewModel.cardViewModel?.url
else { return }
viewModel.urlSelected(url)
},
for: .touchUpInside)
mainStackView.addArrangedSubview(cardView)
mainStackView.addArrangedSubview(bodyView)
contextParentTimeLabel.font = .preferredFont(forTextStyle: .footnote)
contextParentTimeLabel.adjustsFontForContentSizeCategory = true
@ -296,15 +260,10 @@ private extension StatusView {
func applyStatusConfiguration() {
let viewModel = statusConfiguration.viewModel
let isContextParent = viewModel.configuration.isContextParent
let mutableContent = NSMutableAttributedString(attributedString: viewModel.content)
let mutableDisplayName = NSMutableAttributedString(string: viewModel.displayName)
let mutableSpoilerText = NSMutableAttributedString(string: viewModel.spoilerText)
let contentFont = UIFont.preferredFont(forTextStyle: isContextParent ? .title3 : .callout)
let contentRange = NSRange(location: 0, length: mutableContent.length)
avatarImageView.kf.setImage(with: viewModel.avatarURL)
contentTextView.shouldFallthrough = !isContextParent
sideStackView.isHidden = isContextParent
avatarImageView.removeFromSuperview()
@ -326,25 +285,11 @@ private extension StatusView {
inReplyToView.isHidden = !viewModel.configuration.isReplyInContext
hasReplyFollowingView.isHidden = !viewModel.configuration.hasReplyFollowing
if
viewModel.isReblog {
let metaText = String.localizedStringWithFormat(
NSLocalizedString("status.reblogged-by", comment: ""),
viewModel.rebloggedByDisplayName)
let mutableInfoText = NSMutableAttributedString(string: metaText)
let range = (mutableInfoText.string as NSString).range(of: viewModel.rebloggedByDisplayName)
if range.location != NSNotFound,
let boldFontDescriptor = infoLabel.font.fontDescriptor.withSymbolicTraits([.traitBold]) {
let boldFont = UIFont(descriptor: boldFontDescriptor, size: infoLabel.font.pointSize)
mutableInfoText.setAttributes([NSAttributedString.Key.font: boldFont], range: range)
}
mutableInfoText.insert(emoji: viewModel.rebloggedByDisplayNameEmoji, view: infoLabel)
mutableInfoText.resizeAttachments(toLineHeight: infoLabel.font.lineHeight)
infoLabel.attributedText = mutableInfoText
if viewModel.isReblog {
infoLabel.attributedText = "status.reblogged-by".localizedBolding(
displayName: viewModel.rebloggedByDisplayName,
emoji: viewModel.rebloggedByDisplayNameEmoji,
label: infoLabel)
infoIcon.image = UIImage(
systemName: "arrow.2.squarepath",
withConfiguration: UIImage.SymbolConfiguration(scale: .small))
@ -362,33 +307,10 @@ private extension StatusView {
infoIcon.isHidden = true
}
mutableContent.removeAttribute(.font, range: contentRange)
mutableContent.addAttributes(
[.font: contentFont, .foregroundColor: UIColor.label],
range: contentRange)
mutableContent.insert(emoji: viewModel.contentEmoji, view: contentTextView)
mutableContent.resizeAttachments(toLineHeight: contentFont.lineHeight)
contentTextView.attributedText = mutableContent
contentTextView.isHidden = contentTextView.text == ""
mutableDisplayName.insert(emoji: viewModel.displayNameEmoji, view: displayNameLabel)
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
displayNameLabel.attributedText = mutableDisplayName
mutableSpoilerText.insert(emoji: viewModel.contentEmoji, view: spoilerTextLabel)
mutableSpoilerText.resizeAttachments(toLineHeight: spoilerTextLabel.font.lineHeight)
spoilerTextLabel.font = contentFont
spoilerTextLabel.attributedText = mutableSpoilerText
spoilerTextLabel.isHidden = spoilerTextLabel.text == ""
toggleShowContentButton.setTitle(
viewModel.shouldShowContent
? NSLocalizedString("status.show-less", comment: "")
: NSLocalizedString("status.show-more", comment: ""),
for: .normal)
toggleShowContentButton.isHidden = viewModel.spoilerText == ""
contentTextView.isHidden = !viewModel.shouldShowContent
nameAccountTimeStackView.axis = isContextParent ? .vertical : .horizontal
nameAccountTimeStackView.alignment = isContextParent ? .leading : .fill
nameAccountTimeStackView.spacing = isContextParent ? 0 : .compactSpacing
@ -407,14 +329,7 @@ private extension StatusView {
timeLabel.text = viewModel.time
timeLabel.isHidden = isContextParent
attachmentsView.isHidden = viewModel.attachmentViewModels.count == 0
attachmentsView.viewModel = viewModel
pollView.isHidden = viewModel.pollOptions.count == 0
pollView.viewModel = viewModel
cardView.viewModel = viewModel.cardViewModel
cardView.isHidden = viewModel.cardViewModel == nil
bodyView.viewModel = viewModel
contextParentTimeLabel.text = viewModel.contextParentTime
timeApplicationDividerLabel.isHidden = viewModel.applicationName == nil