Announcements

This commit is contained in:
Justin Mazzocchi 2021-04-25 12:38:36 -07:00
parent e6f95ccd18
commit 3f7a6a26aa
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
27 changed files with 787 additions and 14 deletions

View file

@ -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)

View file

@ -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
}

View file

@ -33,6 +33,8 @@ final class TableViewDataSource: UITableViewDiffableDataSource<CollectionSection
conversationCell.viewModel = conversationViewModel
case let (tagCell as TagTableViewCell, tagViewModel as TagViewModel):
tagCell.viewModel = tagViewModel
case let (announcementCell as AnnouncementTableViewCell, announcementViewModel as AnnouncementViewModel):
announcementCell.viewModel = announcementViewModel
case let (_, moreResultsViewModel as MoreResultsViewModel):
var configuration = cell.defaultContentConfiguration()
let statusWord = viewModel.identityContext.appPreferences.statusWord

View file

@ -12,6 +12,7 @@ extension CollectionItem {
NotificationTableViewCell.self,
ConversationTableViewCell.self,
TagTableViewCell.self,
AnnouncementTableViewCell.self,
SeparatorConfiguredTableViewCell.self]
var cellClass: AnyClass {
@ -28,6 +29,8 @@ extension CollectionItem {
return ConversationTableViewCell.self
case .tag:
return TagTableViewCell.self
case .announcement:
return AnnouncementTableViewCell.self
case .moreResults:
return SeparatorConfiguredTableViewCell.self
}
@ -58,6 +61,8 @@ extension CollectionItem {
conversation: conversation)
case let .tag(tag):
return TagView.estimatedHeight(width: width, tag: tag)
case let .announcement(announcement):
return AnnouncementView.estimatedHeight(width: width, announcement: announcement)
case .moreResults:
return UITableView.automaticDimension
}

View file

@ -62,6 +62,7 @@
"account.unnotify" = "Turn off notifications";
"activity.open-in-default-browser" = "Open in default browser";
"add" = "Add";
"announcement.insert-emoji" = "Insert emoji";
"api-error.unable-to-fetch-remote-status" = "Unable to fetch remote status";
"apns-default-message" = "New notification";
"app-icon.brutalist" = "Brutalist";
@ -176,6 +177,7 @@
"load-more.above.accessibility.toot" = "Load from toot above";
"load-more.below.accessibility.post" = "Load from post below";
"load-more.below.accessibility.toot" = "Load from toot below";
"main-navigation.announcements" = "Announcements";
"main-navigation.timelines" = "Timelines";
"main-navigation.explore" = "Explore";
"main-navigation.notifications" = "Notifications";

View file

@ -3,7 +3,7 @@
import Foundation
public struct Announcement: Codable, Hashable {
public let id: String
public let id: Id
public let content: HTML
public let startsAt: Date?
public let endsAt: Date?
@ -16,3 +16,7 @@ public struct Announcement: Codable, Hashable {
public let emojis: [Emoji]
public let reactions: [AnnouncementReaction]
}
public extension Announcement {
typealias Id = String
}

View file

@ -12,6 +12,9 @@ public enum EmptyEndpoint {
case deleteFilter(id: Filter.Id)
case blockDomain(String)
case unblockDomain(String)
case dismissAnnouncement(id: Announcement.Id)
case addAnnouncementReaction(id: Announcement.Id, name: String)
case removeAnnouncementReaction(id: Announcement.Id, name: String)
}
extension EmptyEndpoint: Endpoint {
@ -27,6 +30,8 @@ extension EmptyEndpoint: Endpoint {
return defaultContext + ["filters"]
case .blockDomain, .unblockDomain:
return defaultContext + ["domain_blocks"]
case .dismissAnnouncement, .addAnnouncementReaction, .removeAnnouncementReaction:
return defaultContext + ["announcements"]
}
}
@ -40,14 +45,20 @@ extension EmptyEndpoint: Endpoint {
return [id]
case .blockDomain, .unblockDomain:
return []
case let .dismissAnnouncement(id):
return [id, "dismiss"]
case let .addAnnouncementReaction(id, name), let .removeAnnouncementReaction(id, name):
return [id, "reactions", name]
}
}
public var method: HTTPMethod {
switch self {
case .addAccountsToList, .oauthRevoke, .blockDomain:
case .addAccountsToList, .oauthRevoke, .blockDomain, .dismissAnnouncement:
return .post
case .removeAccountsFromList, .deleteList, .deleteFilter, .unblockDomain:
case .addAnnouncementReaction:
return .put
case .removeAccountsFromList, .deleteList, .deleteFilter, .unblockDomain, .removeAnnouncementReaction:
return .delete
}
}
@ -60,7 +71,7 @@ extension EmptyEndpoint: Endpoint {
return ["account_ids": Array(accountIds)]
case let .blockDomain(domain), let .unblockDomain(domain):
return ["domain": domain]
case .deleteList, .deleteFilter:
case .deleteList, .deleteFilter, .dismissAnnouncement, .addAnnouncementReaction, .removeAnnouncementReaction:
return nil
}
}

View file

@ -87,6 +87,10 @@
D059373E25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D059373D25AB8D5200754FDF /* CompositionPollOptionView.swift */; };
D059373F25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D059373D25AB8D5200754FDF /* CompositionPollOptionView.swift */; };
D059376125ABE2E800754FDF /* XMLUnescaper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D059376025ABE2E800754FDF /* XMLUnescaper.swift */; };
D05A0A5D26363DC700F9BCE1 /* AnnouncementReactionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A0A5C26363DC700F9BCE1 /* AnnouncementReactionCollectionViewCell.swift */; };
D05A0A6726363FEA00F9BCE1 /* AnnouncementReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A0A6626363FEA00F9BCE1 /* AnnouncementReactionView.swift */; };
D05A0A6D2636400100F9BCE1 /* AnnouncementReactionContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A0A6C2636400100F9BCE1 /* AnnouncementReactionContentConfiguration.swift */; };
D05A0A77263644B600F9BCE1 /* AnnouncementReactionsCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A0A76263644B600F9BCE1 /* AnnouncementReactionsCollectionView.swift */; };
D05E688525B55AE8001FB2C6 /* AVURLAsset+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05E688425B55AE8001FB2C6 /* AVURLAsset+Extensions.swift */; };
D0625E59250F092900502611 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusTableViewCell.swift */; };
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; };
@ -195,6 +199,9 @@
D0D4306525F0B93700BE5504 /* AppIconBrutalist@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = D0D4306325F0B93700BE5504 /* AppIconBrutalist@3x.png */; };
D0D4307025F0BBA900BE5504 /* AppIconRainbowBrutalist@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = D0D4306E25F0BBA900BE5504 /* AppIconRainbowBrutalist@2x.png */; };
D0D4307125F0BBA900BE5504 /* AppIconRainbowBrutalist@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = D0D4306F25F0BBA900BE5504 /* AppIconRainbowBrutalist@3x.png */; };
D0D8B0312622398F00A874A4 /* AnnouncementTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D8B0302622398F00A874A4 /* AnnouncementTableViewCell.swift */; };
D0D8B03B262239B100A874A4 /* AnnouncementContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D8B03A262239B100A874A4 /* AnnouncementContentConfiguration.swift */; };
D0D8B04126223A1E00A874A4 /* AnnouncementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D8B04026223A1E00A874A4 /* AnnouncementView.swift */; };
D0D93EBA25D9C70400C622ED /* AutocompleteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93EB925D9C70400C622ED /* AutocompleteItemView.swift */; };
D0D93EC025D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */; };
D0D93EC525D9C75E00C622ED /* AutocompleteItemContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */; };
@ -332,6 +339,10 @@
D059373225AAEA7000754FDF /* CompositionPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionPollView.swift; sourceTree = "<group>"; };
D059373D25AB8D5200754FDF /* CompositionPollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionPollOptionView.swift; sourceTree = "<group>"; };
D059376025ABE2E800754FDF /* XMLUnescaper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XMLUnescaper.swift; sourceTree = "<group>"; };
D05A0A5C26363DC700F9BCE1 /* AnnouncementReactionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementReactionCollectionViewCell.swift; sourceTree = "<group>"; };
D05A0A6626363FEA00F9BCE1 /* AnnouncementReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementReactionView.swift; sourceTree = "<group>"; };
D05A0A6C2636400100F9BCE1 /* AnnouncementReactionContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementReactionContentConfiguration.swift; sourceTree = "<group>"; };
D05A0A76263644B600F9BCE1 /* AnnouncementReactionsCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementReactionsCollectionView.swift; sourceTree = "<group>"; };
D05E688425B55AE8001FB2C6 /* AVURLAsset+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVURLAsset+Extensions.swift"; sourceTree = "<group>"; };
D0625E58250F092900502611 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = "<group>"; };
D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = "<group>"; };
@ -438,6 +449,9 @@
D0D4306325F0B93700BE5504 /* AppIconBrutalist@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIconBrutalist@3x.png"; sourceTree = "<group>"; };
D0D4306E25F0BBA900BE5504 /* AppIconRainbowBrutalist@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIconRainbowBrutalist@2x.png"; sourceTree = "<group>"; };
D0D4306F25F0BBA900BE5504 /* AppIconRainbowBrutalist@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIconRainbowBrutalist@3x.png"; sourceTree = "<group>"; };
D0D8B0302622398F00A874A4 /* AnnouncementTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementTableViewCell.swift; sourceTree = "<group>"; };
D0D8B03A262239B100A874A4 /* AnnouncementContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementContentConfiguration.swift; sourceTree = "<group>"; };
D0D8B04026223A1E00A874A4 /* AnnouncementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementView.swift; sourceTree = "<group>"; };
D0D93EB925D9C70400C622ED /* AutocompleteItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemView.swift; sourceTree = "<group>"; };
D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemContentConfiguration.swift; sourceTree = "<group>"; };
D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemCollectionViewCell.swift; sourceTree = "<group>"; };
@ -550,6 +564,7 @@
D0CEC11F25E35FE100FEF5A6 /* EmojiInsertable.swift */,
D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */,
D0BE97D625D0863E0057E161 /* ImagePastableTextView.swift */,
D05A0A76263644B600F9BCE1 /* AnnouncementReactionsCollectionView.swift */,
D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */,
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */,
D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */,
@ -612,6 +627,7 @@
isa = PBXGroup;
children = (
D0F0B125251A90F400942152 /* AccountTableViewCell.swift */,
D0D8B0302622398F00A874A4 /* AnnouncementTableViewCell.swift */,
D00702282555E51200F38136 /* ConversationTableViewCell.swift */,
D021A60925C36B32008A0C0D /* IdentityTableViewCell.swift */,
D0B8510B25259E56004E0744 /* LoadMoreTableViewCell.swift */,
@ -626,6 +642,7 @@
D021A66F25C3E1F9008A0C0D /* Collection View Cells */ = {
isa = PBXGroup;
children = (
D05A0A5C26363DC700F9BCE1 /* AnnouncementReactionCollectionViewCell.swift */,
D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */,
D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */,
D09D971725C64682007E6394 /* InstanceCollectionViewCell.swift */,
@ -639,6 +656,8 @@
isa = PBXGroup;
children = (
D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */,
D0D8B03A262239B100A874A4 /* AnnouncementContentConfiguration.swift */,
D05A0A6C2636400100F9BCE1 /* AnnouncementReactionContentConfiguration.swift */,
D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */,
D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */,
D07EC7E225B13DD3006DF726 /* EmojiContentConfiguration.swift */,
@ -657,6 +676,8 @@
isa = PBXGroup;
children = (
D0F0B10D251A868200942152 /* AccountView.swift */,
D05A0A6626363FEA00F9BCE1 /* AnnouncementReactionView.swift */,
D0D8B04026223A1E00A874A4 /* AnnouncementView.swift */,
D0D93EB925D9C70400C622ED /* AutocompleteItemView.swift */,
D00702302555F4AE00F38136 /* ConversationView.swift */,
D021A61325C36BFB008A0C0D /* IdentityView.swift */,
@ -1155,6 +1176,7 @@
D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */,
D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */,
D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */,
D0D8B03B262239B100A874A4 /* AnnouncementContentConfiguration.swift in Sources */,
D0BE633725F2D95E001139FA /* AVAudioSession+Extensions.swift in Sources */,
D0477F2C25C6EBAD005C5368 /* OpenInDefaultBrowserActivity.swift in Sources */,
D07F4D9825D493E300F61133 /* MuteView.swift in Sources */,
@ -1198,6 +1220,7 @@
D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */,
D035F87D25B7F61600DC75ED /* TimelinesViewController.swift in Sources */,
D08DFAF725CE20EA0005DA98 /* ScrollableToTop.swift in Sources */,
D05A0A6726363FEA00F9BCE1 /* AnnouncementReactionView.swift in Sources */,
D059373325AAEA7000754FDF /* CompositionPollView.swift in Sources */,
D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */,
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
@ -1227,14 +1250,17 @@
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
D025B14D25C4E482001C69A8 /* ImageCacheConfiguration.swift in Sources */,
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
D0D8B0312622398F00A874A4 /* AnnouncementTableViewCell.swift in Sources */,
D059373E25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */,
D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */,
D035F86F25B7F30E00DC75ED /* MainNavigationView.swift in Sources */,
D0D8B04126223A1E00A874A4 /* AnnouncementView.swift in Sources */,
D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */,
D0D2AC4725BCD289003D5DF2 /* TagView.swift in Sources */,
D0BE97D725D0863E0057E161 /* ImagePastableTextView.swift in Sources */,
D05936F425AA66A600754FDF /* UIView+Extensions.swift in Sources */,
D05936E925AA3F3D00754FDF /* EditAttachmentView.swift in Sources */,
D05A0A6D2636400100F9BCE1 /* AnnouncementReactionContentConfiguration.swift in Sources */,
D035F8C725B96A4000DC75ED /* SecondaryNavigationButton.swift in Sources */,
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */,
D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */,
@ -1248,6 +1274,7 @@
D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */,
D0FCD6AD261AB2DD00113701 /* InstancePickerViewController.swift in Sources */,
D0070252255921B100F38136 /* AccountFieldView.swift in Sources */,
D05A0A77263644B600F9BCE1 /* AnnouncementReactionsCollectionView.swift in Sources */,
D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */,
D0BE97A325CF44310057E161 /* CGRect+Extensions.swift in Sources */,
D00CB2ED2533ACC00080096B /* StatusView.swift in Sources */,
@ -1277,6 +1304,7 @@
D07EC7FD25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */,
D0C7D4D724F7616A001EBDBB /* UIColor+Extensions.swift in Sources */,
D09D971825C64682007E6394 /* InstanceCollectionViewCell.swift in Sources */,
D05A0A5D26363DC700F9BCE1 /* AnnouncementReactionCollectionViewCell.swift in Sources */,
D0D2AC3925BBEC0F003D5DF2 /* CollectionSection+Extensions.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View file

@ -0,0 +1,50 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPI
public struct AnnouncementService {
public let announcement: Announcement
public let navigationService: NavigationService
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
init(announcement: Announcement,
environment: AppEnvironment,
mastodonAPIClient: MastodonAPIClient,
contentDatabase: ContentDatabase) {
self.announcement = announcement
self.mastodonAPIClient = mastodonAPIClient
self.contentDatabase = contentDatabase
navigationService = NavigationService(environment: environment,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
}
}
public extension AnnouncementService {
func dismiss() -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(EmptyEndpoint.dismissAnnouncement(id: announcement.id))
.flatMap { _ in mastodonAPIClient.request(AnnouncementsEndpoint.announcements) }
.flatMap(contentDatabase.update(announcements:))
.eraseToAnyPublisher()
}
func addReaction(name: String) -> AnyPublisher<Never, Error> {
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<Never, Error> {
mastodonAPIClient.request(EmptyEndpoint.removeAnnouncementReaction(id: announcement.id, name: name))
.flatMap { _ in mastodonAPIClient.request(AnnouncementsEndpoint.announcements) }
.flatMap(contentDatabase.update(announcements:))
.eraseToAnyPublisher()
}
}

View file

@ -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<Never, Error> {
mastodonAPIClient.request(AnnouncementsEndpoint.announcements)
.flatMap(contentDatabase.update(announcements:))
.eraseToAnyPublisher()
}
}

View file

@ -82,9 +82,7 @@ public extension IdentityService {
}
func refreshAnnouncements() -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(AnnouncementsEndpoint.announcements)
.flatMap(contentDatabase.update(announcements:))
.eraseToAnyPublisher()
announcementsService().request(maxId: nil, minId: nil, search: nil)
}
func confirmIdentity() -> AnyPublisher<Never, Error> {
@ -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)
}

View file

@ -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,

View file

@ -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
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() {

View file

@ -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:

View file

@ -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)

View file

@ -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,

View file

@ -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
}
}
}

View file

@ -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<AnyPublisher<CollectionItemEvent, Error>, Never>
init(announcementService: AnnouncementService,
identityContext: IdentityContext,
eventsSubject: PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, 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())
}
}

View file

@ -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
}

View file

@ -10,6 +10,7 @@ public final class NavigationViewModel: ObservableObject {
public let navigations: AnyPublisher<Navigation, Never>
@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)
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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))
}
}

View file

@ -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<Int, AnnouncementReaction> = {
let cellRegistration = UICollectionView.CellRegistration
<AnnouncementReactionCollectionViewCell, AnnouncementReaction> { [weak self] in
guard let self = self else { return }
$0.viewModel = AnnouncementReactionViewModel(
announcementReaction: $2,
identityContext: self.announcementConfiguration.viewModel.identityContext)
}
let dataSource = UICollectionViewDiffableDataSource
<Int, AnnouncementReaction>(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<Int, AnnouncementReaction>()
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()
}
}
}

View file

@ -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]
}
}