Announcements
This commit is contained in:
parent
e6f95ccd18
commit
3f7a6a26aa
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue