From 3f7a6a26aae033767c2fb8cb24494fab446292cc Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sun, 25 Apr 2021 12:38:36 -0700 Subject: [PATCH] Announcements --- DB/Sources/DB/Content/ContentDatabase.swift | 19 ++ DB/Sources/DB/Entities/CollectionItem.swift | 3 + Data Sources/TableViewDataSource.swift | 2 + Extensions/CollectionItem+Extensions.swift | 5 + Localizations/en.lproj/Localizable.strings | 2 + .../Mastodon/Entities/Announcement.swift | 6 +- .../MastodonAPI/Endpoints/EmptyEndpoint.swift | 17 +- Metatext.xcodeproj/project.pbxproj | 28 +++ .../Services/AnnouncementService.swift | 50 +++++ .../Services/AnnouncementsService.swift | 33 ++++ .../Services/IdentityService.swift | 14 +- .../Services/NavigationService.swift | 7 + .../EmojiPickerViewController.swift | 19 +- View Controllers/TableViewController.swift | 45 +++++ .../TimelinesViewController.swift | 18 ++ .../Entities/CollectionItemEvent.swift | 2 + .../AnnouncementReactionViewModel.swift | 31 ++++ .../View Models/AnnouncementViewModel.swift | 70 +++++++ .../CollectionItemsViewModel.swift | 18 ++ .../View Models/NavigationViewModel.swift | 11 ++ .../AnnouncementReactionsCollectionView.swift | 53 ++++++ ...nouncementReactionCollectionViewCell.swift | 24 +++ .../AnnouncementContentConfiguration.swift | 18 ++ ...uncementReactionContentConfiguration.swift | 18 ++ .../AnnouncementReactionView.swift | 98 ++++++++++ .../Content Views/AnnouncementView.swift | 175 ++++++++++++++++++ .../AnnouncementTableViewCell.swift | 15 ++ 27 files changed, 787 insertions(+), 14 deletions(-) create mode 100644 ServiceLayer/Sources/ServiceLayer/Services/AnnouncementService.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Services/AnnouncementsService.swift create mode 100644 ViewModels/Sources/ViewModels/View Models/AnnouncementReactionViewModel.swift create mode 100644 ViewModels/Sources/ViewModels/View Models/AnnouncementViewModel.swift create mode 100644 Views/UIKit/AnnouncementReactionsCollectionView.swift create mode 100644 Views/UIKit/Collection View Cells/AnnouncementReactionCollectionViewCell.swift create mode 100644 Views/UIKit/Content Configurations/AnnouncementContentConfiguration.swift create mode 100644 Views/UIKit/Content Configurations/AnnouncementReactionContentConfiguration.swift create mode 100644 Views/UIKit/Content Views/AnnouncementReactionView.swift create mode 100644 Views/UIKit/Content Views/AnnouncementView.swift create mode 100644 Views/UIKit/Table View Cells/AnnouncementTableViewCell.swift diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 7cf2dad..3b56e3e 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -605,6 +605,25 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func announcementCountPublisher() -> AnyPublisher<(total: Int, unread: Int), Error> { + ValueObservation.tracking(Announcement.fetchCount) + .removeDuplicates() + .publisher(in: databaseWriter) + .combineLatest(ValueObservation.tracking(Announcement.fetchCount) + .removeDuplicates() + .publisher(in: databaseWriter)) + .map { (total: $0, unread: $1) } + .eraseToAnyPublisher() + } + + func announcementsPublisher() -> AnyPublisher<[CollectionSection], Error> { + ValueObservation.tracking(Announcement.order(Announcement.Columns.publishedAt).fetchAll) + .removeDuplicates() + .publisher(in: databaseWriter) + .map { [CollectionSection(items: $0.map(CollectionItem.announcement))] } + .eraseToAnyPublisher() + } + func pickerEmojisPublisher() -> AnyPublisher<[Emoji], Error> { ValueObservation.tracking( Emoji.filter(Emoji.Columns.visibleInPicker == true) diff --git a/DB/Sources/DB/Entities/CollectionItem.swift b/DB/Sources/DB/Entities/CollectionItem.swift index 6dbc44e..8f555c5 100644 --- a/DB/Sources/DB/Entities/CollectionItem.swift +++ b/DB/Sources/DB/Entities/CollectionItem.swift @@ -9,6 +9,7 @@ public enum CollectionItem: Hashable { case notification(MastodonNotification, StatusConfiguration?) case conversation(Conversation) case tag(Tag) + case announcement(Announcement) case moreResults(MoreResults) } @@ -63,6 +64,8 @@ public extension CollectionItem { return conversation.id case let .tag(tag): return tag.name + case let .announcement(announcement): + return announcement.id case .moreResults: return nil } diff --git a/Data Sources/TableViewDataSource.swift b/Data Sources/TableViewDataSource.swift index 07c4371..dccb659 100644 --- a/Data Sources/TableViewDataSource.swift +++ b/Data Sources/TableViewDataSource.swift @@ -33,6 +33,8 @@ final class TableViewDataSource: UITableViewDiffableDataSource AnyPublisher { + mastodonAPIClient.request(EmptyEndpoint.dismissAnnouncement(id: announcement.id)) + .flatMap { _ in mastodonAPIClient.request(AnnouncementsEndpoint.announcements) } + .flatMap(contentDatabase.update(announcements:)) + .eraseToAnyPublisher() + } + + func addReaction(name: String) -> AnyPublisher { + mastodonAPIClient.request(EmptyEndpoint.addAnnouncementReaction(id: announcement.id, name: name)) + .flatMap { _ in mastodonAPIClient.request(AnnouncementsEndpoint.announcements) } + .flatMap(contentDatabase.update(announcements:)) + .eraseToAnyPublisher() + } + + func removeReaction(name: String) -> AnyPublisher { + mastodonAPIClient.request(EmptyEndpoint.removeAnnouncementReaction(id: announcement.id, name: name)) + .flatMap { _ in mastodonAPIClient.request(AnnouncementsEndpoint.announcements) } + .flatMap(contentDatabase.update(announcements:)) + .eraseToAnyPublisher() + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AnnouncementsService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AnnouncementsService.swift new file mode 100644 index 0000000..0a2b1a4 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/AnnouncementsService.swift @@ -0,0 +1,33 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import DB +import Foundation +import MastodonAPI + +public struct AnnouncementsService { + public let sections: AnyPublisher<[CollectionSection], Error> + public let navigationService: NavigationService + public let titleLocalizationComponents: AnyPublisher<[String], Never> + + private let mastodonAPIClient: MastodonAPIClient + private let contentDatabase: ContentDatabase + + init(environment: AppEnvironment, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { + self.mastodonAPIClient = mastodonAPIClient + self.contentDatabase = contentDatabase + sections = contentDatabase.announcementsPublisher() + navigationService = NavigationService(environment: environment, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase) + titleLocalizationComponents = Just(["main-navigation.announcements"]).eraseToAnyPublisher() + } +} + +extension AnnouncementsService: CollectionService { + public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher { + mastodonAPIClient.request(AnnouncementsEndpoint.announcements) + .flatMap(contentDatabase.update(announcements:)) + .eraseToAnyPublisher() + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 4f66fed..f827121 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -82,9 +82,7 @@ public extension IdentityService { } func refreshAnnouncements() -> AnyPublisher { - mastodonAPIClient.request(AnnouncementsEndpoint.announcements) - .flatMap(contentDatabase.update(announcements:)) - .eraseToAnyPublisher() + announcementsService().request(maxId: nil, minId: nil, search: nil) } func confirmIdentity() -> AnyPublisher { @@ -185,6 +183,10 @@ public extension IdentityService { contentDatabase.expiredFiltersPublisher() } + func announcementCountPublisher() -> AnyPublisher<(total: Int, unread: Int), Error> { + contentDatabase.announcementCountPublisher() + } + func pickerEmojisPublisher() -> AnyPublisher<[Emoji], Error> { contentDatabase.pickerEmojisPublisher() } @@ -296,6 +298,12 @@ public extension IdentityService { DomainBlocksService(mastodonAPIClient: mastodonAPIClient) } + func announcementsService() -> AnnouncementsService { + AnnouncementsService(environment: environment, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase) + } + func emojiPickerService() -> EmojiPickerService { EmojiPickerService(contentDatabase: contentDatabase) } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift index 689a79a..9ffc66c 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift @@ -112,6 +112,13 @@ public extension NavigationService { contentDatabase: contentDatabase) } + func announcementService(announcement: Announcement) -> AnnouncementService { + AnnouncementService(announcement: announcement, + environment: environment, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase) + } + func timelineService(timeline: Timeline) -> TimelineService { TimelineService(timeline: timeline, environment: environment, diff --git a/View Controllers/EmojiPickerViewController.swift b/View Controllers/EmojiPickerViewController.swift index a5b9679..a6d5869 100644 --- a/View Controllers/EmojiPickerViewController.swift +++ b/View Controllers/EmojiPickerViewController.swift @@ -9,8 +9,8 @@ final class EmojiPickerViewController: UICollectionViewController { private let viewModel: EmojiPickerViewModel private let selectionAction: (EmojiPickerViewController, PickerEmoji) -> Void - private let deletionAction: (EmojiPickerViewController) -> Void - private let searchPresentationAction: (EmojiPickerViewController, UINavigationController) -> Void + private let deletionAction: ((EmojiPickerViewController) -> Void)? + private let searchPresentationAction: ((EmojiPickerViewController, UINavigationController) -> Void)? private let skinToneButton = UIBarButtonItem() private let deleteButton = UIBarButtonItem() private let closeButton = UIBarButtonItem(systemItem: .close) @@ -64,8 +64,8 @@ final class EmojiPickerViewController: UICollectionViewController { init(viewModel: EmojiPickerViewModel, selectionAction: @escaping (EmojiPickerViewController, PickerEmoji) -> Void, - deletionAction: @escaping (EmojiPickerViewController) -> Void, - searchPresentationAction: @escaping (EmojiPickerViewController, UINavigationController) -> Void) { + deletionAction: ((EmojiPickerViewController) -> Void)?, + searchPresentationAction: ((EmojiPickerViewController, UINavigationController) -> Void)?) { self.viewModel = viewModel self.selectionAction = selectionAction self.deletionAction = deletionAction @@ -98,6 +98,7 @@ final class EmojiPickerViewController: UICollectionViewController { presentSearchButton.translatesAutoresizingMaskIntoConstraints = false presentSearchButton.accessibilityLabel = NSLocalizedString("emoji.search", comment: "") presentSearchButton.addAction(UIAction { [weak self] _ in self?.presentSearch() }, for: .touchUpInside) + presentSearchButton.isHidden = searchPresentationAction == nil skinToneButton.accessibilityLabel = NSLocalizedString("emoji.default-skin-tone-button.accessibility-label", comment: "") @@ -111,11 +112,15 @@ final class EmojiPickerViewController: UICollectionViewController { deleteButton.primaryAction = UIAction(image: UIImage(systemName: "delete.left")) { [weak self] _ in guard let self = self else { return } - self.deletionAction(self) + self.deletionAction?(self) } deleteButton.tintColor = .label - navigationItem.rightBarButtonItems = [deleteButton, skinToneButton] + if deletionAction != nil { + navigationItem.rightBarButtonItems = [deleteButton, skinToneButton] + } else { + navigationItem.rightBarButtonItem = skinToneButton + } closeButton.primaryAction = UIAction { [weak self] _ in self?.presentingViewController?.dismiss(animated: true) @@ -228,7 +233,7 @@ private extension EmojiPickerViewController { navigationItem.leftBarButtonItem = closeButton navigationItem.rightBarButtonItems = [self.skinToneButton] collectionView.backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) - searchPresentationAction(self, navigationController) + searchPresentationAction?(self, navigationController) } func reloadVisibleItems() { diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 80b08bb..e704905 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -331,6 +331,13 @@ extension TableViewController: AVPlayerViewControllerDelegate { } } +extension TableViewController: UIPopoverPresentationControllerDelegate { + func adaptivePresentationStyle(for controller: UIPresentationController, + traitCollection: UITraitCollection) -> UIModalPresentationStyle { + .none + } +} + extension TableViewController: ZoomAnimatorDelegate { func transitionWillStartWith(zoomAnimator: ZoomAnimator) { view.layoutIfNeeded() @@ -533,6 +540,10 @@ private extension TableViewController { share(url: url) case let .navigation(navigation): handle(navigation: navigation) + case let .reload(collectionItem): + reload(collectionItem: collectionItem) + case let .presentEmojiPicker(sourceViewTag, selectionAction): + presentEmojiPicker(sourceViewTag: sourceViewTag, selectionAction: selectionAction) case let .attachment(attachmentViewModel, statusViewModel): present(attachmentViewModel: attachmentViewModel, statusViewModel: statusViewModel) case let .compose(identity, inReplyToViewModel, redraft, redraftWasContextParent, directMessageTo): @@ -582,6 +593,40 @@ private extension TableViewController { viewModel.select(indexPath: indexPath) } + func reload(collectionItem: CollectionItem) { + var snapshot = dataSource.snapshot() + + snapshot.reloadItems([collectionItem]) + + dataSource.apply(snapshot, animatingDifferences: false) + } + + func presentEmojiPicker(sourceViewTag: Int, selectionAction: @escaping (String) -> Void) { + guard let fromView = view.viewWithTag(sourceViewTag) else { return } + + let emojiPickerViewModel = EmojiPickerViewModel(identityContext: viewModel.identityContext) + + let emojiPickerController = EmojiPickerViewController( + viewModel: emojiPickerViewModel, + selectionAction: { [weak self] in + selectionAction($1.name) + self?.dismiss(animated: true) + }, + deletionAction: nil, + searchPresentationAction: nil) + let navigationController = UINavigationController(rootViewController: emojiPickerController) + + navigationController.preferredContentSize = .init( + width: view.readableContentGuide.layoutFrame.width, + height: view.frame.height / 2) + navigationController.modalPresentationStyle = .popover + navigationController.popoverPresentationController?.delegate = self + navigationController.popoverPresentationController?.sourceView = fromView + navigationController.popoverPresentationController?.backgroundColor = .clear + + present(navigationController, animated: true) + } + func present(attachmentViewModel: AttachmentViewModel, statusViewModel: StatusViewModel) { switch attachmentViewModel.attachment.type { case .audio, .video: diff --git a/View Controllers/TimelinesViewController.swift b/View Controllers/TimelinesViewController.swift index 9e5c788..678dcb2 100644 --- a/View Controllers/TimelinesViewController.swift +++ b/View Controllers/TimelinesViewController.swift @@ -6,6 +6,7 @@ import ViewModels final class TimelinesViewController: UIPageViewController { private let segmentedControl = UISegmentedControl() + private let announcementsButton = UIBarButtonItem() private let timelineViewControllers: [TableViewController] private let viewModel: NavigationViewModel private let rootViewModel: RootViewModel @@ -39,6 +40,23 @@ final class TimelinesViewController: UIPageViewController { title: NSLocalizedString("main-navigation.timelines", comment: ""), image: UIImage(systemName: "newspaper"), selectedImage: nil) + + announcementsButton.primaryAction = UIAction( + title: NSLocalizedString("main-navigation.announcements", comment: ""), + image: UIImage(systemName: "megaphone")) { [weak self] _ in + guard let self = self else { return } + + let announcementsViewController = TableViewController(viewModel: viewModel.announcementsViewModel(), + rootViewModel: rootViewModel) + + self.navigationController?.pushViewController(announcementsViewController, animated: true) + } + + viewModel.$announcementCount + .sink { [weak self] in + self?.navigationItem.rightBarButtonItem = $0.total > 0 ? self?.announcementsButton : nil + } + .store(in: &cancellables) } @available(*, unavailable) diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift index f75b89a..7d3f6b8 100644 --- a/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItemEvent.swift @@ -9,6 +9,8 @@ public enum CollectionItemEvent { case contextParentDeleted case refresh case navigation(Navigation) + case reload(CollectionItem) + case presentEmojiPicker(sourceViewTag: Int, selectionAction: (String) -> Void) case attachment(AttachmentViewModel, StatusViewModel) case compose(identity: Identity? = nil, inReplyTo: StatusViewModel? = nil, diff --git a/ViewModels/Sources/ViewModels/View Models/AnnouncementReactionViewModel.swift b/ViewModels/Sources/ViewModels/View Models/AnnouncementReactionViewModel.swift new file mode 100644 index 0000000..3801a38 --- /dev/null +++ b/ViewModels/Sources/ViewModels/View Models/AnnouncementReactionViewModel.swift @@ -0,0 +1,31 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Foundation +import Mastodon + +public struct AnnouncementReactionViewModel { + let identityContext: IdentityContext + + private let announcementReaction: AnnouncementReaction + + public init(announcementReaction: AnnouncementReaction, identityContext: IdentityContext) { + self.announcementReaction = announcementReaction + self.identityContext = identityContext + } +} + +public extension AnnouncementReactionViewModel { + var name: String { announcementReaction.name } + + var count: Int { announcementReaction.count } + + var me: Bool { announcementReaction.me } + + var url: URL? { + if identityContext.appPreferences.animateCustomEmojis { + return announcementReaction.url?.url + } else { + return announcementReaction.staticUrl?.url + } + } +} diff --git a/ViewModels/Sources/ViewModels/View Models/AnnouncementViewModel.swift b/ViewModels/Sources/ViewModels/View Models/AnnouncementViewModel.swift new file mode 100644 index 0000000..40f8876 --- /dev/null +++ b/ViewModels/Sources/ViewModels/View Models/AnnouncementViewModel.swift @@ -0,0 +1,70 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import ServiceLayer + +public final class AnnouncementViewModel: ObservableObject { + public let identityContext: IdentityContext + + private let announcementService: AnnouncementService + private let eventsSubject: PassthroughSubject, Never> + + init(announcementService: AnnouncementService, + identityContext: IdentityContext, + eventsSubject: PassthroughSubject, Never>) { + self.announcementService = announcementService + self.identityContext = identityContext + self.eventsSubject = eventsSubject + } +} + +public extension AnnouncementViewModel { + var announcement: Announcement { announcementService.announcement } +} + +public extension AnnouncementViewModel { + func urlSelected(_ url: URL) { + eventsSubject.send( + announcementService.navigationService.item(url: url) + .map { .navigation($0) } + .setFailureType(to: Error.self) + .eraseToAnyPublisher()) + } + + func dismiss() { + eventsSubject.send( + announcementService.dismiss() + .map { _ in .ignorableOutput } + .eraseToAnyPublisher()) + } + + func reload() { + eventsSubject.send(Just(.reload(.announcement(announcementService.announcement))) + .setFailureType(to: Error.self) + .eraseToAnyPublisher()) + } + + func addReaction(name: String) { + eventsSubject.send( + announcementService.addReaction(name: name) + .map { _ in .ignorableOutput } + .eraseToAnyPublisher()) + } + + func removeReaction(name: String) { + eventsSubject.send( + announcementService.removeReaction(name: name) + .map { _ in .ignorableOutput } + .eraseToAnyPublisher()) + } + + func presentEmojiPicker(sourceViewTag: Int) { + eventsSubject.send(Just(.presentEmojiPicker( + sourceViewTag: sourceViewTag, + selectionAction: { [weak self] in self?.addReaction(name: $0) })) + .setFailureType(to: Error.self) + .eraseToAnyPublisher()) + } +} diff --git a/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift index 7050b9e..303ae85 100644 --- a/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift @@ -194,6 +194,20 @@ public class CollectionItemsViewModel: ObservableObject { viewModelCache[item] = viewModel + return viewModel + case let .announcement(announcement): + if let cachedViewModel = cachedViewModel { + return cachedViewModel + } + + let viewModel = AnnouncementViewModel( + announcementService: collectionService.navigationService.announcementService( + announcement: announcement), + identityContext: identityContext, + eventsSubject: eventsSubject) + + viewModelCache[item] = viewModel + return viewModel case let .moreResults(moreResults): if let cachedViewModel = cachedViewModel { @@ -300,6 +314,8 @@ extension CollectionItemsViewModel: CollectionViewModel { send(event: .navigation(.collection(collectionService .navigationService .timelineService(timeline: .tag(tag.name))))) + case .announcement: + break case let .moreResults(moreResults): searchScopeChangesSubject.send(moreResults.scope) } @@ -320,6 +336,8 @@ extension CollectionItemsViewModel: CollectionViewModel { return !configuration.isContextParent case .loadMore: return !((viewModel(indexPath: indexPath) as? LoadMoreViewModel)?.loading ?? false) + case .announcement: + return false default: return true } diff --git a/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift index daccc2d..304d6f5 100644 --- a/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/NavigationViewModel.swift @@ -10,6 +10,7 @@ public final class NavigationViewModel: ObservableObject { public let navigations: AnyPublisher @Published public private(set) var recentIdentities = [Identity]() + @Published public private(set) var announcementCount: (total: Int, unread: Int) = (0, 0) @Published public var presentedNewStatusViewModel: NewStatusViewModel? @Published public var presentingSecondaryNavigation = false @Published public var alertItem: AlertItem? @@ -28,6 +29,10 @@ public final class NavigationViewModel: ObservableObject { identityContext.service.recentIdentitiesPublisher() .assignErrorsToAlertItem(to: \.alertItem, on: self) .assign(to: &$recentIdentities) + + identityContext.service.announcementCountPublisher() + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .assign(to: &$announcementCount) } } @@ -191,4 +196,10 @@ public extension NavigationViewModel { return conversationsViewModel } + + func announcementsViewModel() -> CollectionViewModel { + CollectionItemsViewModel( + collectionService: identityContext.service.announcementsService(), + identityContext: identityContext) + } } diff --git a/Views/UIKit/AnnouncementReactionsCollectionView.swift b/Views/UIKit/AnnouncementReactionsCollectionView.swift new file mode 100644 index 0000000..a8f04b4 --- /dev/null +++ b/Views/UIKit/AnnouncementReactionsCollectionView.swift @@ -0,0 +1,53 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit + +final class AnnouncementReactionsCollectionView: UICollectionView { + + init() { + super.init(frame: .zero, collectionViewLayout: Self.layout()) + + backgroundColor = .clear + isScrollEnabled = false + showsVerticalScrollIndicator = false + showsHorizontalScrollIndicator = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + if bounds.size != intrinsicContentSize { + invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: max(contentSize.height, .minimumButtonDimension)) + } +} + +private extension AnnouncementReactionsCollectionView { + static func layout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize( + widthDimension: .estimated(.minimumButtonDimension), + heightDimension: .estimated(.minimumButtonDimension)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(.minimumButtonDimension)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + group.interItemSpacing = .flexible(.defaultSpacing) + + let section = NSCollectionLayoutSection(group: group) + + section.interGroupSpacing = .defaultSpacing + + return UICollectionViewCompositionalLayout(section: section) + } +} diff --git a/Views/UIKit/Collection View Cells/AnnouncementReactionCollectionViewCell.swift b/Views/UIKit/Collection View Cells/AnnouncementReactionCollectionViewCell.swift new file mode 100644 index 0000000..9e50ff1 --- /dev/null +++ b/Views/UIKit/Collection View Cells/AnnouncementReactionCollectionViewCell.swift @@ -0,0 +1,24 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +final class AnnouncementReactionCollectionViewCell: UICollectionViewCell { + var viewModel: AnnouncementReactionViewModel? + + override func updateConfiguration(using state: UICellConfigurationState) { + guard let viewModel = viewModel else { return } + + contentConfiguration = AnnouncementReactionContentConfiguration(viewModel: viewModel) + + var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell().updated(for: state) + + if !state.isHighlighted && !state.isSelected { + backgroundConfiguration.backgroundColor = .clear + } + + backgroundConfiguration.cornerRadius = .defaultCornerRadius + + self.backgroundConfiguration = backgroundConfiguration + } +} diff --git a/Views/UIKit/Content Configurations/AnnouncementContentConfiguration.swift b/Views/UIKit/Content Configurations/AnnouncementContentConfiguration.swift new file mode 100644 index 0000000..e8ca10c --- /dev/null +++ b/Views/UIKit/Content Configurations/AnnouncementContentConfiguration.swift @@ -0,0 +1,18 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +struct AnnouncementContentConfiguration { + let viewModel: AnnouncementViewModel +} + +extension AnnouncementContentConfiguration: UIContentConfiguration { + func makeContentView() -> UIView & UIContentView { + AnnouncementView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> AnnouncementContentConfiguration { + self + } +} diff --git a/Views/UIKit/Content Configurations/AnnouncementReactionContentConfiguration.swift b/Views/UIKit/Content Configurations/AnnouncementReactionContentConfiguration.swift new file mode 100644 index 0000000..54dc0f3 --- /dev/null +++ b/Views/UIKit/Content Configurations/AnnouncementReactionContentConfiguration.swift @@ -0,0 +1,18 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +struct AnnouncementReactionContentConfiguration { + let viewModel: AnnouncementReactionViewModel +} + +extension AnnouncementReactionContentConfiguration: UIContentConfiguration { + func makeContentView() -> UIView & UIContentView { + AnnouncementReactionView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> AnnouncementReactionContentConfiguration { + self + } +} diff --git a/Views/UIKit/Content Views/AnnouncementReactionView.swift b/Views/UIKit/Content Views/AnnouncementReactionView.swift new file mode 100644 index 0000000..d171eae --- /dev/null +++ b/Views/UIKit/Content Views/AnnouncementReactionView.swift @@ -0,0 +1,98 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import SDWebImage +import UIKit +import ViewModels + +final class AnnouncementReactionView: UIView { + private let nameLabel = UILabel() + private let imageView = SDAnimatedImageView() + private let countLabel = UILabel() + private var announcementReactionConfiguration: AnnouncementReactionContentConfiguration + + init(configuration: AnnouncementReactionContentConfiguration) { + announcementReactionConfiguration = configuration + + super.init(frame: .zero) + + initialSetup() + applyAnnouncementReactionConfiguration() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension AnnouncementReactionView: UIContentView { + var configuration: UIContentConfiguration { + get { announcementReactionConfiguration } + set { + guard let announcementReactionConfiguration = newValue as? AnnouncementReactionContentConfiguration else { + return + } + + self.announcementReactionConfiguration = announcementReactionConfiguration + + applyAnnouncementReactionConfiguration() + } + } +} + +private extension AnnouncementReactionView { + static let meBackgroundColor = UIColor.link.withAlphaComponent(0.5) + static let backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.5) + func initialSetup() { + layer.cornerRadius = .defaultCornerRadius + + let stackView = UIStackView() + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = .defaultSpacing + + stackView.addArrangedSubview(imageView) + imageView.contentMode = .scaleAspectFit + + stackView.addArrangedSubview(nameLabel) + nameLabel.adjustsFontForContentSizeCategory = true + nameLabel.textAlignment = .center + nameLabel.adjustsFontSizeToFitWidth = true + nameLabel.font = .preferredFont(forTextStyle: .body) + + stackView.addArrangedSubview(countLabel) + countLabel.adjustsFontForContentSizeCategory = true + countLabel.font = .preferredFont(forTextStyle: .headline) + countLabel.textColor = .link + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + imageView.widthAnchor.constraint(equalToConstant: .minimumButtonDimension / 2), + imageView.heightAnchor.constraint(equalToConstant: .minimumButtonDimension / 2), + nameLabel.widthAnchor.constraint(equalToConstant: .minimumButtonDimension / 2), + nameLabel.heightAnchor.constraint(equalToConstant: .minimumButtonDimension / 2) + ]) + + isAccessibilityElement = true + } + + func applyAnnouncementReactionConfiguration() { + let viewModel = announcementReactionConfiguration.viewModel + + backgroundColor = viewModel.me ? Self.meBackgroundColor : Self.backgroundColor + + nameLabel.text = viewModel.name + nameLabel.isHidden = viewModel.url != nil + + imageView.sd_setImage(with: viewModel.url) + imageView.isHidden = viewModel.url == nil + + countLabel.text = String(viewModel.count) + + accessibilityLabel = viewModel.name.appendingWithSeparator(String(viewModel.count)) + } +} diff --git a/Views/UIKit/Content Views/AnnouncementView.swift b/Views/UIKit/Content Views/AnnouncementView.swift new file mode 100644 index 0000000..9669867 --- /dev/null +++ b/Views/UIKit/Content Views/AnnouncementView.swift @@ -0,0 +1,175 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Mastodon +import UIKit +import ViewModels + +final class AnnouncementView: UIView { + private let contentTextView = TouchFallthroughTextView() + private let reactionButton = UIButton() + private let reactionsCollectionView = AnnouncementReactionsCollectionView() + private var announcementConfiguration: AnnouncementContentConfiguration + + init(configuration: AnnouncementContentConfiguration) { + announcementConfiguration = configuration + + super.init(frame: .zero) + + initialSetup() + applyAnnouncementConfiguration() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private lazy var dataSource: UICollectionViewDiffableDataSource = { + let cellRegistration = UICollectionView.CellRegistration + { [weak self] in + guard let self = self else { return } + + $0.viewModel = AnnouncementReactionViewModel( + announcementReaction: $2, + identityContext: self.announcementConfiguration.viewModel.identityContext) + } + + let dataSource = UICollectionViewDiffableDataSource + (collectionView: reactionsCollectionView) { + $0.dequeueConfiguredReusableCell(using: cellRegistration, for: $1, item: $2) + } + + return dataSource + }() +} + +extension AnnouncementView { + static func estimatedHeight(width: CGFloat, announcement: Announcement) -> CGFloat { + UITableView.automaticDimension + } +} + +extension AnnouncementView: UIContentView { + var configuration: UIContentConfiguration { + get { announcementConfiguration } + set { + guard let announcementConfiguration = newValue as? AnnouncementContentConfiguration else { return } + + self.announcementConfiguration = announcementConfiguration + + applyAnnouncementConfiguration() + } + } +} + +extension AnnouncementView: UITextViewDelegate { + func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction) -> Bool { + switch interaction { + case .invokeDefaultAction: + announcementConfiguration.viewModel.urlSelected(URL) + return false + case .preview: return false + case .presentActions: return false + @unknown default: return false + } + } +} + +extension AnnouncementView: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: true) + + guard let reaction = dataSource.itemIdentifier(for: indexPath) else { return } + + if reaction.me { + announcementConfiguration.viewModel.removeReaction(name: reaction.name) + } else { + announcementConfiguration.viewModel.addReaction(name: reaction.name) + } + + UISelectionFeedbackGenerator().selectionChanged() + } +} + +private extension AnnouncementView { + func initialSetup() { + let stackView = UIStackView() + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = .defaultSpacing + + contentTextView.adjustsFontForContentSizeCategory = true + contentTextView.backgroundColor = .clear + contentTextView.delegate = self + stackView.addArrangedSubview(contentTextView) + + let reactionStackView = UIStackView() + + stackView.addArrangedSubview(reactionStackView) + reactionStackView.spacing = .defaultSpacing + reactionStackView.alignment = .top + + reactionStackView.addArrangedSubview(reactionButton) + reactionButton.tag = UUID().hashValue + reactionButton.accessibilityLabel = NSLocalizedString("announcement.insert-emoji", comment: "") + reactionButton.setImage( + UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(scale: .large)), + for: .normal) + reactionButton.addAction( + UIAction { [weak self] _ in + guard let self = self else { return } + + self.announcementConfiguration.viewModel.presentEmojiPicker(sourceViewTag: self.reactionButton.tag) + }, + for: .touchUpInside) + + reactionStackView.addArrangedSubview(reactionsCollectionView) + reactionsCollectionView.delegate = self + reactionsCollectionView.setContentCompressionResistancePriority(.required, for: .vertical) + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), + stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor), + reactionButton.widthAnchor.constraint(equalToConstant: .minimumButtonDimension), + reactionButton.heightAnchor.constraint(equalToConstant: .minimumButtonDimension) + ]) + } + + func applyAnnouncementConfiguration() { + let viewModel = announcementConfiguration.viewModel + let mutableContent = NSMutableAttributedString(attributedString: viewModel.announcement.content.attributed) + let contentFont = UIFont.preferredFont(forTextStyle: .callout) + let contentRange = NSRange(location: 0, length: mutableContent.length) + + mutableContent.removeAttribute(.font, range: contentRange) + mutableContent.addAttributes( + [.font: contentFont, .foregroundColor: UIColor.label], + range: contentRange) + mutableContent.insert(emojis: viewModel.announcement.emojis, + view: contentTextView, + identityContext: viewModel.identityContext) + mutableContent.resizeAttachments(toLineHeight: contentFont.lineHeight) + contentTextView.attributedText = mutableContent + + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections([0]) + snapshot.appendItems(viewModel.announcement.reactions, toSection: 0) + + if snapshot.itemIdentifiers != dataSource.snapshot().itemIdentifiers { + dataSource.apply(snapshot, animatingDifferences: false) { viewModel.reload() } + } + + if !viewModel.announcement.read { + viewModel.dismiss() + } + } +} diff --git a/Views/UIKit/Table View Cells/AnnouncementTableViewCell.swift b/Views/UIKit/Table View Cells/AnnouncementTableViewCell.swift new file mode 100644 index 0000000..33d197f --- /dev/null +++ b/Views/UIKit/Table View Cells/AnnouncementTableViewCell.swift @@ -0,0 +1,15 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +final class AnnouncementTableViewCell: SeparatorConfiguredTableViewCell { + var viewModel: AnnouncementViewModel? + + override func updateConfiguration(using state: UICellConfigurationState) { + guard let viewModel = viewModel else { return } + + contentConfiguration = AnnouncementContentConfiguration(viewModel: viewModel).updated(for: state) + accessibilityElements = [contentView] + } +}