2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Show ungrouped notifications as before, but using the new MastodonFeedItemIdentifiers

Contributes to IOS-253
Contributes to IOS-355
Contributes to IOS-357
This commit is contained in:
shannon 2025-01-10 16:08:38 -05:00
parent 100937187c
commit 1ec772d8a1
16 changed files with 592 additions and 59 deletions

View File

@ -145,6 +145,14 @@ extension StatusSection {
switch item {
case .history:
return nil
case .pollOption(let option):
// Fix cell reuse animation issue
let cell: PollOptionTableViewCell = {
let _cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self) + "@\(indexPath.row)#\(indexPath.section)") as? PollOptionTableViewCell
_cell?.prepareForReuse()
return _cell ?? PollOptionTableViewCell()
}()
return cell
case .option(let record):
// Fix cell reuse animation issue
let cell: PollOptionTableViewCell = {
@ -175,6 +183,8 @@ extension StatusSection {
) {
statusView.pollTableViewDiffableDataSource = UITableViewDiffableDataSource<PollSection, PollItem>(tableView: statusView.pollTableView) { tableView, indexPath, item in
switch item {
case .pollOption:
return nil
case .option:
return nil
case let .history(option):

View File

@ -243,7 +243,7 @@ extension DataSourceFacade {
authenticationBox: dependency.authenticationBox,
account: menuContext.author,
relationship: relationship,
status: menuContext.statusViewModel?.originalStatus,
status: menuContext.statusViewModel?._originalStatus,
contentDisplayMode: .neverConceal
)
@ -270,7 +270,7 @@ extension DataSourceFacade {
transition: .activityViewControllerPresent(animated: true, completion: nil)
)
case .bookmarkStatus:
guard let status = menuContext.statusViewModel?.originalStatus else {
guard let status = menuContext.statusViewModel?._originalStatus else {
assertionFailure()
return
}
@ -279,7 +279,7 @@ extension DataSourceFacade {
status: status
)
case .shareStatus:
guard let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else {
guard let status: MastodonStatus = menuContext.statusViewModel?._originalStatus?.reblog ?? menuContext.statusViewModel?._originalStatus else {
assertionFailure()
return
}
@ -310,7 +310,7 @@ extension DataSourceFacade {
style: .destructive
) { [weak dependency] _ in
guard let dependency else { return }
guard let status = menuContext.statusViewModel?.originalStatus else { return }
guard let status = menuContext.statusViewModel?._originalStatus else { return }
performDeletion(of: status, with: dependency)
}
alertController.addAction(confirmAction)
@ -318,11 +318,11 @@ extension DataSourceFacade {
alertController.addAction(cancelAction)
dependency.present(alertController, animated: true)
} else {
guard let status = menuContext.statusViewModel?.originalStatus else { return }
guard let status = menuContext.statusViewModel?._originalStatus else { return }
performDeletion(of: status, with: dependency)
}
case .translateStatus:
guard let status = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else { return }
guard let status = menuContext.statusViewModel?._originalStatus?.reblog ?? menuContext.statusViewModel?._originalStatus else { return }
do {
let translation = try await DataSourceFacade.translateStatus(provider: dependency, status: status)
@ -336,7 +336,7 @@ extension DataSourceFacade {
}
case .editStatus:
guard let status = menuContext.statusViewModel?.originalStatus else { return }
guard let status = menuContext.statusViewModel?._originalStatus else { return }
let statusSource = try await APIService.shared.getStatusSource(
forStatusID: status.id,
@ -391,21 +391,21 @@ extension DataSourceFacade {
alertController.addAction(cancelAction)
dependency.present(alertController, animated: true)
case .boostStatus(_):
guard let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else {
guard let status: MastodonStatus = menuContext.statusViewModel?._originalStatus?.reblog ?? menuContext.statusViewModel?._originalStatus else {
assertionFailure()
return
}
try await responseToStatusReblogAction(provider: dependency, status: status)
case .favoriteStatus(_):
guard let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else {
guard let status: MastodonStatus = menuContext.statusViewModel?._originalStatus?.reblog ?? menuContext.statusViewModel?._originalStatus else {
assertionFailure()
return
}
try await responseToStatusFavoriteAction(provider: dependency, status: status)
case .copyStatusLink:
guard let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus else {
guard let status: MastodonStatus = menuContext.statusViewModel?._originalStatus?.reblog ?? menuContext.statusViewModel?._originalStatus else {
assertionFailure()
return
}
@ -413,7 +413,7 @@ extension DataSourceFacade {
UIPasteboard.general.string = status.entity.url
case .openStatusInBrowser:
guard
let status: MastodonStatus = menuContext.statusViewModel?.originalStatus?.reblog ?? menuContext.statusViewModel?.originalStatus,
let status: MastodonStatus = menuContext.statusViewModel?._originalStatus?.reblog ?? menuContext.statusViewModel?._originalStatus,
let urlString = status.entity.url,
let url = URL(string: urlString)
else {

View File

@ -172,11 +172,15 @@ extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateT
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
let sectionCount = viewModel.diffableDataSource?.numberOfSections(in: tableView) ?? 0
let rowCount = viewModel.diffableDataSource?.tableView(tableView, numberOfRowsInSection: indexPath.section) ?? 0
let isLastItem = indexPath.section == sectionCount - 1 && indexPath.row == rowCount - 1
guard isLastItem, let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
return
}
// check item type inside `loadMore`
Task {
await viewModel.loadMore(item: item)
}

View File

@ -37,7 +37,7 @@ extension NotificationTimelineViewModel {
Task {
let oldSnapshot = diffableDataSource.snapshot()
var newSnapshot: NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem> = {
let newSnapshot: NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem> = {
let newItems = records.map { record in
NotificationItem.notification(record)
}

View File

@ -36,7 +36,71 @@ extension NotificationView {
extension NotificationView {
public func configure(notificationItem: MastodonFeedItemIdentifier) {
assertionFailure("not implemented")
let item = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem)
guard let notification = item as? Mastodon.Entity.Notification, let authBox = AuthenticationServiceProvider.shared.currentActiveUser.value else { assert(false); return }
func contentDisplayMode(_ status: Mastodon.Entity.Status) -> StatusView.ContentDisplayMode {
let contentDisplayModel = StatusView.ContentConcealViewModel(status: status, filterBox: StatusFilterService.shared.activeFilterBox, filterContext: .notifications, showDespiteFilter: MastodonFeedItemCacheManager.shared.shouldShowDespiteFilter(statusID: status.id), showDespiteContentWarning: MastodonFeedItemCacheManager.shared.shouldShowDespiteContentWarning(statusID: status.id))
return contentDisplayModel.effectiveDisplayMode
}
switch notification.type {
case .follow:
setAuthorContainerBottomPaddingViewDisplay(isHidden: true)
case .followRequest:
setFollowRequestAdaptiveMarginContainerViewDisplay(isHidden: true)
case .mention, .status:
if let status = notification.status {
statusView.configure(status: status, contentDisplayMode: contentDisplayMode(status))
setStatusViewDisplay()
}
case .reblog, .favourite, .poll:
if let status = notification.status {
quoteStatusView.configure(status: status, contentDisplayMode: contentDisplayMode(status))
setQuoteStatusViewDisplay()
}
case .moderationWarning:
// case handled in `AccountWarningNotificationCell.swift`
break
case ._other:
setAuthorContainerBottomPaddingViewDisplay()
assertionFailure()
}
configure(notification: notification, authenticationBox: authBox)
}
public func configure(notification: Mastodon.Entity.Notification, authenticationBox: MastodonAuthenticationBox) {
configureAuthor(notification: notification, authenticationBox: authenticationBox)
func contentDisplayMode(_ status: Mastodon.Entity.Status) -> StatusView.ContentDisplayMode {
let contentDisplayModel = StatusView.ContentConcealViewModel(status: status, filterBox: StatusFilterService.shared.activeFilterBox, filterContext: .notifications, showDespiteFilter: MastodonFeedItemCacheManager.shared.shouldShowDespiteFilter(statusID: status.id), showDespiteContentWarning: MastodonFeedItemCacheManager.shared.shouldShowDespiteContentWarning(statusID: status.id))
return contentDisplayModel.effectiveDisplayMode
}
switch notification.type {
case .follow:
setAuthorContainerBottomPaddingViewDisplay(isHidden: true)
case .followRequest:
setFollowRequestAdaptiveMarginContainerViewDisplay(isHidden: false)
case .mention, .status:
if let status = notification.status {
statusView.configure(status: status, contentDisplayMode: contentDisplayMode(status))
setStatusViewDisplay()
}
case .reblog, .favourite, .poll:
if let status = notification.status {
quoteStatusView.configure(status: status, contentDisplayMode: contentDisplayMode(status))
setQuoteStatusViewDisplay()
}
case .moderationWarning:
// case handled in `AccountWarningNotificationCell.swift`
break
case ._other:
setAuthorContainerBottomPaddingViewDisplay()
assertionFailure()
}
}
public func configure(notification: MastodonNotification, authenticationBox: MastodonAuthenticationBox) {
@ -72,8 +136,217 @@ extension NotificationView {
}
private func configureAuthor(notificationItem: MastodonItemIdentifier) {
assertionFailure("not implemented")
private func configureAuthor(notification: Mastodon.Entity.Notification, authenticationBox: MastodonAuthenticationBox) {
let author = notification.account
// author avatar
avatarButton.avatarImageView.configure(with: author.avatarImageURL())
avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 12)))
// author name
let metaAuthorName: MetaContent
do {
let content = MastodonContent(content: author.displayNameWithFallback, emojis: author.emojis.asDictionary)
metaAuthorName = try MastodonMetaContent.convert(document: content)
} catch {
assertionFailure(error.localizedDescription)
metaAuthorName = PlaintextMetaContent(string: author.displayNameWithFallback)
}
authorNameLabel.configure(content: metaAuthorName)
// username
let metaUsername = PlaintextMetaContent(string: "@\(author.acct)")
authorUsernameLabel.configure(content: metaUsername)
// notification type indicator
let notificationIndicatorText: MetaContent?
if let type = MastodonNotificationType(rawValue: notification.type.rawValue) {
// TODO: fix the i18n. The subject should assert place at the string beginning
func createMetaContent(text: String, emojis: MastodonContent.Emojis) -> MetaContent {
let content = MastodonContent(content: text, emojis: emojis)
guard let metaContent = try? MastodonMetaContent.convert(document: content) else {
return PlaintextMetaContent(string: text)
}
return metaContent
}
switch type {
case .follow:
notificationIndicatorText = createMetaContent(
text: L10n.Scene.Notification.NotificationDescription.followedYou,
emojis: author.emojis.asDictionary
)
case .followRequest:
notificationIndicatorText = createMetaContent(
text: L10n.Scene.Notification.NotificationDescription.requestToFollowYou,
emojis: author.emojis.asDictionary
)
case .mention:
notificationIndicatorText = createMetaContent(
text: L10n.Scene.Notification.NotificationDescription.mentionedYou,
emojis: author.emojis.asDictionary
)
case .reblog:
notificationIndicatorText = createMetaContent(
text: L10n.Scene.Notification.NotificationDescription.rebloggedYourPost,
emojis: author.emojis.asDictionary
)
case .favourite:
notificationIndicatorText = createMetaContent(
text: L10n.Scene.Notification.NotificationDescription.favoritedYourPost,
emojis: author.emojis.asDictionary
)
case .poll:
notificationIndicatorText = createMetaContent(
text: L10n.Scene.Notification.NotificationDescription.pollHasEnded,
emojis: author.emojis.asDictionary
)
case .status:
notificationIndicatorText = createMetaContent(
text: .empty,
emojis: author.emojis.asDictionary
)
case ._other:
notificationIndicatorText = nil
}
var actions = [UIAccessibilityCustomAction]()
// these notifications can be directly actioned to view the profile
if type != .follow, type != .followRequest {
actions.append(
UIAccessibilityCustomAction(
name: L10n.Common.Controls.Status.showUserProfile,
image: nil
) { [weak self] _ in
guard let self, let delegate = self.delegate else { return false }
delegate.notificationView(self, authorAvatarButtonDidPressed: self.avatarButton)
return true
}
)
}
if type == .followRequest {
actions.append(
UIAccessibilityCustomAction(
name: L10n.Common.Controls.Actions.confirm,
image: Asset.Editing.checkmark20.image
) { [weak self] _ in
guard let self, let delegate = self.delegate else { return false }
delegate.notificationView(self, acceptFollowRequestButtonDidPressed: self.acceptFollowRequestButton)
return true
}
)
actions.append(
UIAccessibilityCustomAction(
name: L10n.Common.Controls.Actions.delete,
image: Asset.Circles.forbidden20.image
) { [weak self] _ in
guard let self, let delegate = self.delegate else { return false }
delegate.notificationView(self, rejectFollowRequestButtonDidPressed: self.rejectFollowRequestButton)
return true
}
)
}
notificationActions = actions
} else {
notificationIndicatorText = nil
notificationActions = []
}
if let notificationIndicatorText {
notificationTypeIndicatorLabel.configure(content: notificationIndicatorText)
} else {
notificationTypeIndicatorLabel.reset()
}
if let me = authenticationBox.cachedAccount {
let isMyself = (author == me)
let isMuting: Bool
let isBlocking: Bool
if let relationship = MastodonFeedItemCacheManager.shared.currentRelationship(toAccount: notification.account.id) {
isMuting = relationship.muting
isBlocking = relationship.blocking || relationship.domainBlocking
} else {
isMuting = false
isBlocking = false
}
let menuContext = NotificationView.AuthorMenuContext(name: metaAuthorName.string, isMuting: isMuting, isBlocking: isBlocking, isMyself: isMyself)
let (menu, actions) = setupAuthorMenu(menuContext: menuContext)
menuButton.menu = menu
authorActions = actions
menuButton.showsMenuAsPrimaryAction = true
menuButton.isHidden = menuContext.isMyself
}
timestampUpdatePublisher
.prepend(Date())
.eraseToAnyPublisher()
.sink { [weak self] now in
guard let self, let type = MastodonNotificationType(rawValue: notification.type.rawValue) else { return }
let formattedTimestamp = notification.createdAt.localizedAbbreviatedSlowedTimeAgoSinceNow
dateLabel.configure(content: PlaintextMetaContent(string: formattedTimestamp))
self.accessibilityLabel = [
"\(author.displayNameWithFallback) \(type)",
author.acct,
formattedTimestamp
].joined(separator: ", ")
if self.statusView.isHidden == false {
self.accessibilityLabel! += ", " + (self.statusView.accessibilityLabel ?? "")
}
if self.quoteStatusViewContainerView.isHidden == false {
self.accessibilityLabel! += ", " + (self.quoteStatusView.accessibilityLabel ?? "")
}
}
.store(in: &disposeBag)
if notification.type == .followRequest {
let followRequestState = MastodonFeedItemCacheManager.shared.followRequestState(forFollowRequestNotification: notification.id).state
switch followRequestState {
case .none:
break
case .isAccept:
self.rejectFollowRequestButtonShadowBackgroundContainer.isHidden = true
self.acceptFollowRequestButton.isUserInteractionEnabled = false
self.acceptFollowRequestButton.setImage(nil, for: .normal)
self.acceptFollowRequestButton.setTitle(L10n.Scene.Notification.FollowRequest.accepted, for: .normal)
case .isReject:
self.acceptFollowRequestButtonShadowBackgroundContainer.isHidden = true
self.rejectFollowRequestButton.isUserInteractionEnabled = false
self.rejectFollowRequestButton.setImage(nil, for: .normal)
self.rejectFollowRequestButton.setTitle(L10n.Scene.Notification.FollowRequest.rejected, for: .normal)
case .isAccepting:
self.acceptFollowRequestActivityIndicatorView.startAnimating()
self.acceptFollowRequestButton.tintColor = .clear
self.acceptFollowRequestButton.setTitleColor(.clear, for: .normal)
case .isRejecting:
self.rejectFollowRequestActivityIndicatorView.startAnimating()
self.rejectFollowRequestButton.tintColor = .clear
self.rejectFollowRequestButton.setTitleColor(.clear, for: .normal)
}
if !followRequestState.isTransient {
followRequestAdaptiveMarginContainerView.isHidden = false
self.acceptFollowRequestActivityIndicatorView.stopAnimating()
self.acceptFollowRequestButton.tintColor = .white
self.acceptFollowRequestButton.setTitleColor(.white, for: .normal)
self.rejectFollowRequestActivityIndicatorView.stopAnimating()
self.rejectFollowRequestButton.tintColor = .black
self.rejectFollowRequestButton.setTitleColor(.black, for: .normal)
}
} else {
followRequestAdaptiveMarginContainerView.isHidden = true
}
}
private func configureAuthor(notification: MastodonNotification, authenticationBox: MastodonAuthenticationBox) {
@ -292,3 +565,14 @@ extension NotificationView {
}
}
}
extension MastodonFollowRequestState.State {
var isTransient: Bool {
switch self {
case .none, .isAccept, .isReject:
return false
case .isAccepting, .isRejecting:
return true
}
}
}

View File

@ -64,6 +64,37 @@ extension PollOptionView {
// appearance
checkmarkBackgroundView.backgroundColor = SystemTheme.tableViewCellSelectionBackgroundColor
}
public func configure(poll: Mastodon.Entity.Poll, option pollOption: Mastodon.Entity.Poll.Option, isMyPoll: Bool) {
// metaContent
viewModel.metaContent = PlaintextMetaContent(string: pollOption.title)
// percentage
let denominator = poll.votersCount ?? poll.votesCount
let optionVotesCount = pollOption.votesCount ?? 0
if denominator > 0, optionVotesCount >= 0 {
viewModel.percentage = Double(optionVotesCount) / Double(denominator)
} else {
viewModel.percentage = 0
}
// expiration
viewModel.isExpire = poll.expired
// isMultiple
viewModel.isMultiple = poll.multiple
if let isSelectedIndex = poll.options.firstIndex(of: pollOption) {
viewModel.isSelect = poll.ownVotes?.contains(isSelectedIndex) ?? false
} else {
viewModel.isSelect = false
}
viewModel.isPollVoted = poll.voted == true
viewModel.isMyPoll = isMyPoll
// appearance
checkmarkBackgroundView.backgroundColor = SystemTheme.tableViewCellSelectionBackgroundColor
}
}
extension PollOptionView {

View File

@ -274,7 +274,8 @@ private extension MastodonFeedLoader {
}
return notifications.map {
MastodonFeedItemIdentifier.notification(id: $0.id)
MastodonFeedItemCacheManager.shared.addToCache($0)
return MastodonFeedItemIdentifier.notification(id: $0.id)
}
}

View File

@ -11,6 +11,8 @@ import CoreDataStack
import MastodonSDK
public enum PollItem: Hashable {
@available(*, deprecated, message: "migrate to pollOption wrapping a Mastodon.Entity.Poll.Option")
case option(record: MastodonPollOption)
case pollOption(Mastodon.Entity.Poll.Option)
case history(option: Mastodon.Entity.StatusEdit.Poll.Option)
}

View File

@ -11,9 +11,9 @@ import Foundation
extension Mastodon.API.Notifications {
internal static func notificationsEndpointURL(domain: String, grouped: Bool = false) -> URL {
if grouped {
Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications")
} else {
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications")
} else {
Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications")
}
}

View File

@ -20,16 +20,27 @@ extension Mastodon.Entity {
public typealias ID = String
public let id: ID
public let expiresAt: Date? // if nil the poll does not end
/// if nil the poll does not end
public let expiresAt: Date?
public let expired: Bool
/// Does the poll allow multiple-choice answers?/
public let multiple: Bool
/// How many votes have been received./
public let votesCount: Int
/// nil if `multiple` is false
/// How many unique accounts have voted on a multiple-choice poll. nil if `multiple` is false
public let votersCount: Int?
/// nil if no current user
/// When called with a user token, has the authorized user voted? nil if no current user.
public let voted: Bool?
/// nil if no current user
/// When called with a user token, which options has the authorized user chosen? Contains an array of index values for options. nil if no current user.
public let ownVotes: [Int]?
public let options: [Option]
enum CodingKeys: String, CodingKey {

View File

@ -143,6 +143,9 @@ public class MastodonFeedItemCacheManager {
private var relationshipsCache = [ String : Mastodon.Entity.Relationship ]()
private var fullAccountsCache = [ String : Mastodon.Entity.Account ]()
private var partialAccountsCache = [ String : Mastodon.Entity.PartialAccountWithAvatar ]()
private var filterOverrides = Set<String>()
private var contentWarningOverrides = Set<String>()
private var followRequestStates = [ String : MastodonFollowRequestState ]()
private init(){}
public static let shared = MastodonFeedItemCacheManager()
@ -194,16 +197,15 @@ public class MastodonFeedItemCacheManager {
guard let statusID = notificationGroup.statusID else { return nil }
let status = statusCache[statusID]
return status?.reblog ?? status
} else if let relationship = cachedItem as? Mastodon.Entity.Relationship {
return nil
// } else if let relationship = cachedItem as? Mastodon.Entity.Relationship {
// return nil
} else {
return nil
}
}
public func relationship(associatedWith accountID: MastodonFeedItemIdentifier) -> Mastodon.Entity.Relationship? {
assertionFailure("not implemented")
return nil
public func currentRelationship(toAccount accountID: String) -> Mastodon.Entity.Relationship? {
return relationshipsCache[accountID]
}
public func partialAccount(_ id: String) -> Mastodon.Entity.PartialAccountWithAvatar? {
@ -215,4 +217,52 @@ public class MastodonFeedItemCacheManager {
assertionFailure("not implemented")
return nil
}
private func contentStatusID(forStatus statusID: String) -> String {
guard let status = statusCache[statusID] else { return statusID }
return status.reblog?.id ?? statusID
}
public func shouldShowDespiteContentWarning(statusID: String) -> Bool {
return contentWarningOverrides.contains(contentStatusID(forStatus: statusID))
}
public func shouldShowDespiteFilter(statusID: String) -> Bool {
return filterOverrides.contains(contentStatusID(forStatus: statusID))
}
public func toggleFilteredVisibility(ofStatus statusID: String) {
let contentStatusID = contentStatusID(forStatus: statusID)
if filterOverrides.contains(contentStatusID) {
filterOverrides.remove(contentStatusID)
} else {
filterOverrides.insert(contentStatusID)
}
}
public func toggleContentWarnedVisibility(ofStatus statusID: String) {
let contentStatusID = contentStatusID(forStatus: statusID)
if contentWarningOverrides.contains(contentStatusID) {
contentWarningOverrides.remove(contentStatusID)
} else {
contentWarningOverrides.insert(contentStatusID)
}
}
public func followRequestState(forFollowRequestNotification notificationID: String) -> MastodonFollowRequestState {
if let requestState = followRequestStates[notificationID] {
return requestState
} else {
return .init(state: .none)
}
}
public func setFollowRequestState(_ requestState: MastodonFollowRequestState, for followRequestID: String) {
switch requestState.state {
case .none:
followRequestStates.removeValue(forKey: followRequestID)
default:
followRequestStates[followRequestID] = requestState
}
}
}

View File

@ -26,6 +26,23 @@ public enum ContentWarning {
}
}
public init(status: Mastodon.Entity.Status) {
let statusWithContent = status.reblog ?? status
let hasSpoilerText = statusWithContent.spoilerText != nil && !statusWithContent.spoilerText!.isEmpty
let isMarkedSensitive = statusWithContent.sensitive ?? false
let fallbackWarningText = ""
switch (hasSpoilerText, isMarkedSensitive) {
case (true, true):
self = .warnWholePost(message: statusWithContent.spoilerText ?? fallbackWarningText)
case (true, false):
self = .warnWholePost(message: statusWithContent.spoilerText ?? fallbackWarningText)
case (false, true):
self = .warnMediaOnly
case (false, false):
self = .warnNothing
}
}
public init(statusEdit: Mastodon.Entity.StatusEdit) {
let entity = statusEdit
let hasSpoilerText = entity.spoilerText != nil && !entity.spoilerText!.isEmpty

View File

@ -181,6 +181,10 @@ extension MediaView.Configuration {
extension MediaView {
public static func configuration(status: MastodonStatus, contentDisplayMode: StatusView.ContentDisplayMode) -> [MediaView.Configuration] {
return configuration(status: status.entity, contentDisplayMode: contentDisplayMode)
}
public static func configuration(status: Mastodon.Entity.Status, contentDisplayMode: StatusView.ContentDisplayMode) -> [MediaView.Configuration] {
func videoInfo(from attachment: MastodonAttachment) -> MediaView.Configuration.VideoInfo {
MediaView.Configuration.VideoInfo(
aspectRadio: attachment.size,
@ -191,7 +195,7 @@ extension MediaView {
)
}
let attachments = status.entity.mastodonAttachments
let attachments = status.mastodonAttachments
let configurations = attachments.enumerated().map { (idx, attachment) -> MediaView.Configuration in
let configuration: MediaView.Configuration = {
switch attachment.kind {

View File

@ -76,7 +76,7 @@ extension StatusView {
configurePoll(status: status)
configureCard(status: status)
configureToolbar(status: status)
viewModel.originalStatus = status
viewModel._originalStatus = status
viewModel.$translation
.receive(on: DispatchQueue.main)
@ -194,7 +194,11 @@ extension StatusView {
}
private func configureHeader(status: MastodonStatus) {
if status.entity.reblogged == true,
configureHeader(status: status.entity)
}
private func configureHeader(status: Mastodon.Entity.Status) {
if status.reblogged == true,
let authenticationBox = viewModel.authenticationBox,
let account = authenticationBox.cachedAccount {
@ -213,8 +217,8 @@ extension StatusView {
}
}()
} else if status.reblog != nil {
let name = status.entity.account.displayNameWithFallback
let emojis = status.entity.account.emojis
let name = status.account.displayNameWithFallback
let emojis = status.account.emojis
viewModel.header = {
let text = L10n.Common.Controls.Status.userReblogged(name)
@ -228,8 +232,8 @@ extension StatusView {
}
}()
} else if let _ = status.entity.inReplyToID,
let inReplyToAccountID = status.entity.inReplyToAccountID
} else if let _ = status.inReplyToID,
let inReplyToAccountID = status.inReplyToAccountID
{
func createHeader(
name: String?,
@ -251,7 +255,7 @@ extension StatusView {
return header
}
if let inReplyToID = status.entity.inReplyToID {
if let inReplyToID = status.inReplyToID {
// A. replyTo status exist
/// we need to initially set an empty header, otherwise the layout gets messed up
@ -369,13 +373,20 @@ extension StatusView {
}
public func revertTranslation() {
guard let originalStatus = viewModel.originalStatus else { return }
viewModel.translation = nil
configure(status: originalStatus, contentDisplayMode: contentDisplayMode)
if let originalStatus = viewModel._originalStatus {
viewModel.translation = nil
configure(status: originalStatus, contentDisplayMode: contentDisplayMode)
} else if let untranslatedStatus = viewModel.untranslatedStatus {
viewModel.translation = nil
configure(status: untranslatedStatus, contentDisplayMode: contentDisplayMode)
}
}
func configureTranslated(status: MastodonStatus) {
configureTranslated(status: status.entity)
}
func configureTranslated(status: Mastodon.Entity.Status) {
guard let translation = viewModel.translation,
let translatedContent = translation.content else {
viewModel.isCurrentlyTranslating = false
@ -384,7 +395,7 @@ extension StatusView {
// content
do {
let content = MastodonContent(content: translatedContent, emojis: status.entity.emojis.asDictionary)
let content = MastodonContent(content: translatedContent, emojis: status.emojis.asDictionary)
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.content = metaContent
viewModel.isCurrentlyTranslating = false
@ -414,17 +425,22 @@ extension StatusView {
}
private func configureContent(status: MastodonStatus) {
configureContent(status: status.entity)
}
private func configureContent(status: Mastodon.Entity.Status) {
guard viewModel.translation == nil else {
return configureTranslated(status: status)
configureTranslated(status: status)
return
}
let status = status.reblog ?? status
// spoilerText
if let spoilerText = status.entity.spoilerText, !spoilerText.isEmpty {
if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
do {
let content = MastodonContent(content: spoilerText, emojis: status.entity.emojis.asDictionary)
let content = MastodonContent(content: spoilerText, emojis: status.emojis.asDictionary)
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.spoilerContent = metaContent
} catch {
@ -436,10 +452,10 @@ extension StatusView {
}
// language
viewModel.language = (status.reblog ?? status).entity.language
viewModel.language = (status.reblog ?? status).language
// content
do {
let content = MastodonContent(content: status.entity.content ?? "", emojis: status.entity.emojis.asDictionary)
let content = MastodonContent(content: status.content ?? "", emojis: status.emojis.asDictionary)
let metaContent = try MastodonMetaContent.convert(document: content)
viewModel.content = metaContent
viewModel.isCurrentlyTranslating = false
@ -448,7 +464,7 @@ extension StatusView {
viewModel.content = PlaintextMetaContent(string: "")
}
// visibility
viewModel.visibility = status.entity.mastodonVisibility
viewModel.visibility = status.mastodonVisibility
}
private func configureMedia(status: MastodonStatus, contentDisplayMode: ContentDisplayMode) {
@ -457,6 +473,12 @@ extension StatusView {
viewModel.mediaViewConfigurations = configurations
}
private func configureMedia(status: Mastodon.Entity.Status, contentDisplayMode: ContentDisplayMode) {
let status = status.reblog ?? status
let configurations = MediaView.configuration(status: status, contentDisplayMode: contentDisplayMode)
viewModel.mediaViewConfigurations = configurations
}
private func configureMedia(status: Mastodon.Entity.StatusEdit, contentDisplayMode: ContentDisplayMode) {
let configurations = MediaView.configuration(status: status, contentDisplayMode: contentDisplayMode)
viewModel.mediaViewConfigurations = configurations
@ -519,31 +541,87 @@ extension StatusView {
.assign(to: \.isVoting, on: viewModel)
.store(in: &disposeBag)
}
private func configurePoll(status: Mastodon.Entity.Status) {
let status = status.reblog ?? status
guard let poll = status.poll else {
return
}
let options = poll.options
let items: [PollItem] = options.map { .pollOption($0) }
self.viewModel.pollItems = items
viewModel.isVoteButtonEnabled = !viewModel.selectedPollItems.isEmpty
viewModel.voteCount = poll.votesCount
viewModel.voterCount = poll.votersCount
viewModel.expireAt = poll.expiresAt
viewModel.expired = poll.expired
viewModel.isVoting = poll.voted ?? false
}
private func configureCard(status: MastodonStatus) {
configureCard(status: status.entity)
}
private func configureCard(status: Mastodon.Entity.Status) {
let status = status.reblog ?? status
if viewModel.mediaViewConfigurations.isEmpty {
viewModel.card = status.entity.card
viewModel.card = status.card
} else {
viewModel.card = nil
}
}
private func configureToolbar(status: MastodonStatus) {
configureToolbar(status: status.entity)
}
private func configureToolbar(status: Mastodon.Entity.Status) {
let status = status.reblog ?? status
viewModel.replyCount = status.entity.repliesCount ?? 0
viewModel.replyCount = status.repliesCount ?? 0
viewModel.reblogCount = status.entity.reblogsCount
viewModel.reblogCount = status.reblogsCount
viewModel.favoriteCount = status.entity.favouritesCount
viewModel.favoriteCount = status.favouritesCount
viewModel.editedAt = status.entity.editedAt
viewModel.editedAt = status.editedAt
// relationship
viewModel.isReblog = status.entity.reblogged == true
viewModel.isFavorite = status.entity.favourited == true
viewModel.isBookmark = status.entity.bookmarked == true
viewModel.isReblog = status.reblogged == true
viewModel.isFavorite = status.favourited == true
viewModel.isBookmark = status.bookmarked == true
}
}
extension StatusView {
public func configure(status: Mastodon.Entity.Status, contentDisplayMode: ContentDisplayMode) {
viewModel.contentDisplayMode = contentDisplayMode
configureHeader(status: status)
let author = (status.reblog ?? status).account
configureAuthor(author: author)
let timestamp = (status.reblog ?? status).createdAt
configureTimestamp(timestamp: timestamp)
configureApplicationName(status.application?.name)
configureContent(status: status)
configureMedia(status: status, contentDisplayMode: contentDisplayMode)
configurePoll(status: status)
configureCard(status: status)
configureToolbar(status: status)
viewModel.untranslatedStatus = status
viewModel.$translation
.receive(on: DispatchQueue.main)
.sink { [weak self] translation in
self?.configureTranslated(status: status)
}
.store(in: &disposeBag)
configureForContentDisplayMode()
}
}

View File

@ -26,10 +26,11 @@ extension StatusView {
public var managedObjects = Set<NSManagedObject>()
public var authenticationBox: MastodonAuthenticationBox?
public var originalStatus: MastodonStatus? {
public var untranslatedStatus: Mastodon.Entity.Status?
public var _originalStatus: MastodonStatus? {
didSet {
// Note: the originalStatus is created fresh every time, so never canceling this subscription is ok for now.
originalStatus?.$entity
_originalStatus?.$entity
.receive(on: DispatchQueue.main)
.sink(receiveValue: { status in
self.isBookmark = status.bookmarked == true
@ -83,6 +84,7 @@ extension StatusView {
// Poll
@Published public var pollItems: [PollItem] = []
@Published public var selectedPollItems = IndexSet() // when using .pollOption, selection information has to be stored separately. deprecated .option wrapped that information inside the contained MastodonPollOption.
@Published public var isVotable: Bool = false
@Published public var isVoting: Bool = false
@Published public var isVoteButtonEnabled: Bool = false

View File

@ -89,6 +89,45 @@ public final class StatusView: UIView {
}
}
public init(status: Mastodon.Entity.Status?, filterBox: Mastodon.Entity.FilterBox?, filterContext: Mastodon.Entity.FilterContext?, showDespiteFilter: Bool, showDespiteContentWarning: Bool) {
let status = (status?.reblog ?? status)
if let status, let filterContext, let filterBox {
let filterPrefix = "\(L10n.Common.Controls.Timeline.filtered) \""
let filterResult = filterBox.apply(to: status, in: filterContext)
switch filterResult {
case .notFiltered:
filtered = .neverConceal
case .hide(let reason):
filtered = .concealAll(reason: filterPrefix + reason + "\"", showAnyway: showDespiteFilter)
case .warn(let reason):
filtered = .concealAll(reason: filterPrefix + reason + "\"", showAnyway: showDespiteFilter)
}
} else {
filtered = .neverConceal
}
if let status {
let contentWarning = ContentWarning(status: status)
switch contentWarning {
case .warnNothing:
contentWarned = .neverConceal
case .warnMediaOnly:
contentWarned = .concealMediaOnly(showAnyway: showDespiteContentWarning)
case .warnWholePost(let message):
contentWarned = .concealAll(reason: message, showAnyway: showDespiteContentWarning)
}
} else {
contentWarned = .neverConceal
}
}
func contentDisplayMode(_ status: Mastodon.Entity.Status, showDespiteFilter: Bool, showDespiteContentWarning: Bool) -> StatusView.ContentDisplayMode {
let contentDisplayModel = StatusView.ContentConcealViewModel(status: status, filterBox: StatusFilterService.shared.activeFilterBox, filterContext: .notifications, showDespiteFilter: showDespiteFilter, showDespiteContentWarning: showDespiteContentWarning)
return contentDisplayModel.effectiveDisplayMode
}
public var effectiveDisplayMode: ContentDisplayMode {
switch (filtered.shouldConcealSomething, contentWarned.shouldConcealSomething) {
case (true, _):