From 1ec772d8a1182f9c662c522e4fb88469710307a6 Mon Sep 17 00:00:00 2001 From: shannon Date: Fri, 10 Jan 2025 16:08:38 -0500 Subject: [PATCH] Show ungrouped notifications as before, but using the new MastodonFeedItemIdentifiers Contributes to IOS-253 Contributes to IOS-355 Contributes to IOS-357 --- Mastodon/Diffable/Status/StatusSection.swift | 10 + .../Provider/DataSourceFacade+Status.swift | 22 +- .../NotificationTimelineViewController.swift | 10 +- ...tificationTimelineViewModel+Diffable.swift | 2 +- .../NotificationView+Configuration.swift | 290 +++++++++++++++++- .../PollOptionView+Configuration.swift | 31 ++ .../DataController/MastodonFeedLoader.swift | 3 +- .../MastodonCore/Model/Poll/PollItem.swift | 2 + .../API/Mastodon+API+Notifications.swift | 4 +- .../Entity/Mastodon+Entity+Poll.swift | 19 +- .../Sources/MastodonSDK/MastodonFeed.swift | 60 +++- .../Sources/MastodonSDK/MastodonStatus.swift | 17 + .../Content/MediaView+Configuration.swift | 6 +- .../Content/StatusView+Configuration.swift | 130 ++++++-- .../View/Content/StatusView+ViewModel.swift | 6 +- .../MastodonUI/View/Content/StatusView.swift | 39 +++ 16 files changed, 592 insertions(+), 59 deletions(-) diff --git a/Mastodon/Diffable/Status/StatusSection.swift b/Mastodon/Diffable/Status/StatusSection.swift index 865531ee8..907c5b4d5 100644 --- a/Mastodon/Diffable/Status/StatusSection.swift +++ b/Mastodon/Diffable/Status/StatusSection.swift @@ -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(tableView: statusView.pollTableView) { tableView, indexPath, item in switch item { + case .pollOption: + return nil case .option: return nil case let .history(option): diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index b3cf265db..f55275f7d 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -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 { diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift index 3a7acd035..95426a2d9 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -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) } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift index 1398d29ae..7fd300dd6 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift @@ -37,7 +37,7 @@ extension NotificationTimelineViewModel { Task { let oldSnapshot = diffableDataSource.snapshot() - var newSnapshot: NSDiffableDataSourceSnapshot = { + let newSnapshot: NSDiffableDataSourceSnapshot = { let newItems = records.map { record in NotificationItem.notification(record) } diff --git a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift index 656d8c49a..dd9847ff2 100644 --- a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift +++ b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift @@ -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 + } + } +} diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift index 2dae10900..9a05ba316 100644 --- a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift @@ -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 { diff --git a/MastodonSDK/Sources/MastodonCore/DataController/MastodonFeedLoader.swift b/MastodonSDK/Sources/MastodonCore/DataController/MastodonFeedLoader.swift index e97a11dfd..b0311d422 100644 --- a/MastodonSDK/Sources/MastodonCore/DataController/MastodonFeedLoader.swift +++ b/MastodonSDK/Sources/MastodonCore/DataController/MastodonFeedLoader.swift @@ -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) } } diff --git a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift index 01eb06bcb..a1d0c9250 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift @@ -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) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index 30e720430..c9a287c14 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -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") } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Poll.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Poll.swift index 7e93629c6..d1cad3d53 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Poll.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Poll.swift @@ -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 { diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift index 4898a3842..85f27fbe6 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift @@ -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() + private var contentWarningOverrides = Set() + 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 + } + } } diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift index 78c5a92b9..33a768434 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonStatus.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift index a372ce558..b4ed4232f 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift @@ -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 { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 5d69e757c..1d4a0243a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -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() + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 45dcda856..03d19fd27 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -26,10 +26,11 @@ extension StatusView { public var managedObjects = Set() 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 diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 2a7266141..e3d87b66d 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -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, _):