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:
parent
100937187c
commit
1ec772d8a1
@ -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):
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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, _):
|
||||
|
Loading…
x
Reference in New Issue
Block a user