diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 6328e38..2e2c883 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -452,7 +452,8 @@ public extension ContentDatabase { .init(showContentToggled: $0.showContentToggled, showAttachmentsToggled: $0.showAttachmentsToggled)) }, - titleLocalizedStringKey: "search.statuses") + titleLocalizedStringKey: "search.statuses"), + .init(items: results.hashtags.map(CollectionItem.tag), titleLocalizedStringKey: "search.tags") ] } .eraseToAnyPublisher() diff --git a/DB/Sources/DB/Entities/CollectionItem.swift b/DB/Sources/DB/Entities/CollectionItem.swift index d0df88a..35a4e81 100644 --- a/DB/Sources/DB/Entities/CollectionItem.swift +++ b/DB/Sources/DB/Entities/CollectionItem.swift @@ -8,6 +8,7 @@ public enum CollectionItem: Hashable { case account(Account) case notification(MastodonNotification, StatusConfiguration?) case conversation(Conversation) + case tag(Tag) } public extension CollectionItem { @@ -48,6 +49,8 @@ public extension CollectionItem { return notification.id case let .conversation(conversation): return conversation.id + case let .tag(tag): + return tag.name } } } diff --git a/Data Sources/TableViewDataSource.swift b/Data Sources/TableViewDataSource.swift index 5b72d3b..1494c49 100644 --- a/Data Sources/TableViewDataSource.swift +++ b/Data Sources/TableViewDataSource.swift @@ -28,6 +28,8 @@ final class TableViewDataSource: UITableViewDiffableDataSource + tag.people-talking + + NSStringLocalizedFormatKey + %#@people@ + people + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld person talking + other + %ld people talking + + status.poll.participation-count NSStringLocalizedFormatKey diff --git a/Mastodon/Sources/Mastodon/Entities/Tag.swift b/Mastodon/Sources/Mastodon/Entities/Tag.swift index d173fdb..266ae87 100644 --- a/Mastodon/Sources/Mastodon/Entities/Tag.swift +++ b/Mastodon/Sources/Mastodon/Entities/Tag.swift @@ -5,4 +5,13 @@ import Foundation public struct Tag: Codable, Hashable { public let name: String public let url: URL + public let history: [History]? +} + +public extension Tag { + struct History: Codable, Hashable { + public let day: String + public let uses: String + public let accounts: String + } } diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 38877d5..8d76eac 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -130,6 +130,10 @@ D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */; }; D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */; }; D0D2AC3925BBEC0F003D5DF2 /* CollectionSection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC3825BBEC0F003D5DF2 /* CollectionSection+Extensions.swift */; }; + D0D2AC4725BCD289003D5DF2 /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC4625BCD289003D5DF2 /* TagView.swift */; }; + D0D2AC4D25BCD2A9003D5DF2 /* TagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC4C25BCD2A9003D5DF2 /* TagTableViewCell.swift */; }; + D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC5225BCD2BA003D5DF2 /* TagContentConfiguration.swift */; }; + D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */; }; D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DD50CA256B1F24004A04F7 /* ReportView.swift */; }; D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */; }; D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; }; @@ -303,6 +307,10 @@ D0C7D46F24F76169001EBDBB /* View+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadView.swift; sourceTree = ""; }; D0D2AC3825BBEC0F003D5DF2 /* CollectionSection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionSection+Extensions.swift"; sourceTree = ""; }; + D0D2AC4625BCD289003D5DF2 /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; }; + D0D2AC4C25BCD2A9003D5DF2 /* TagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTableViewCell.swift; sourceTree = ""; }; + D0D2AC5225BCD2BA003D5DF2 /* TagContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentConfiguration.swift; sourceTree = ""; }; + D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = ""; }; D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = ""; }; D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = ""; }; D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = ""; }; @@ -509,6 +517,7 @@ D07EC7F125B13E57006DF726 /* EmojiView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, + D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */, D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */, D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, D0B8510B25259E56004E0744 /* LoadMoreCell.swift */, @@ -537,6 +546,9 @@ D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */, D0625E55250F086B00502611 /* Status */, D0C7D42524F76169001EBDBB /* TableView.swift */, + D0D2AC5225BCD2BA003D5DF2 /* TagContentConfiguration.swift */, + D0D2AC4C25BCD2A9003D5DF2 /* TagTableViewCell.swift */, + D0D2AC4625BCD289003D5DF2 /* TagView.swift */, D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */, D0EA59472522B8B600804347 /* ViewConstants.swift */, D0F2D54A2581CF7D00986197 /* VisualEffectBlur.swift */, @@ -882,6 +894,8 @@ D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */, D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */, D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */, + D0D2AC4D25BCD2A9003D5DF2 /* TagTableViewCell.swift in Sources */, + D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */, D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */, D08B8D72254246E200B1EBEF /* PollView.swift in Sources */, D035F8A925B9155900DC75ED /* NewStatusButtonView.swift in Sources */, @@ -894,6 +908,7 @@ D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */, D035F86F25B7F30E00DC75ED /* MainNavigationView.swift in Sources */, D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */, + D0D2AC4725BCD289003D5DF2 /* TagView.swift in Sources */, D05936F425AA66A600754FDF /* UIView+Extensions.swift in Sources */, D05936E925AA3F3D00754FDF /* EditAttachmentView.swift in Sources */, D035F8C725B96A4000DC75ED /* SecondaryNavigationButton.swift in Sources */, @@ -918,6 +933,7 @@ D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */, D0F2D54B2581CF7D00986197 /* VisualEffectBlur.swift in Sources */, D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */, + D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */, D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */, D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift index a80bd35..3b17ebb 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift @@ -86,6 +86,10 @@ public extension NavigationService { mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + + func timelineService(timeline: Timeline) -> TimelineService { + TimelineService(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) + } } private extension NavigationService { diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index b60ac96..6f8b80c 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -154,6 +154,11 @@ extension CollectionItemsViewModel: CollectionViewModel { .navigation(.collection(collectionService .navigationService .contextService(id: status.displayStatus.id)))) + case let .tag(tag): + eventsSubject.send( + .navigation(.collection(collectionService + .navigationService + .timelineService(timeline: .tag(tag.name))))) } } @@ -255,6 +260,16 @@ extension CollectionItemsViewModel: CollectionViewModel { cache(viewModel: viewModel, forItem: item) + return viewModel + case let .tag(tag): + if let cachedViewModel = cachedViewModel { + return cachedViewModel + } + + let viewModel = TagViewModel(tag: tag) + + cache(viewModel: viewModel, forItem: item) + return viewModel } } diff --git a/ViewModels/Sources/ViewModels/TagViewModel.swift b/ViewModels/Sources/ViewModels/TagViewModel.swift new file mode 100644 index 0000000..2eeee8a --- /dev/null +++ b/ViewModels/Sources/ViewModels/TagViewModel.swift @@ -0,0 +1,50 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon + +public struct TagViewModel: CollectionItemViewModel { + public let events: AnyPublisher, Never> + + private let tag: Tag + + init(tag: Tag) { + self.tag = tag + events = Empty().eraseToAnyPublisher() + } +} + +public extension TagViewModel { + var name: String { "#".appending(tag.name) } + + var accounts: Int? { + guard let history = tag.history, + let accountsString = history.first?.accounts, + var accounts = Int(accountsString) + else { return nil } + + if history.count > 1, let secondDayAccounts = Int(history[1].accounts) { + accounts += secondDayAccounts + } + + return accounts + } + + var uses: Int? { + guard let history = tag.history, + let usesString = history.first?.uses, + var uses = Int(usesString) + else { return nil } + + if history.count > 1, let secondDayUses = Int(history[1].uses) { + uses += secondDayUses + } + + return uses + } + + var usageHistory: [Int] { + tag.history?.compactMap { Int($0.uses) } ?? [] + } +} diff --git a/Views/LineChartView.swift b/Views/LineChartView.swift new file mode 100644 index 0000000..b60cc90 --- /dev/null +++ b/Views/LineChartView.swift @@ -0,0 +1,56 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit + +final class LineChartView: UIView { + var values = [Int]() { + didSet { setNeedsDisplay() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + UIView.layoutFittingExpandedSize + } + + override func draw(_ rect: CGRect) { + let path = UIBezierPath() + + path.lineWidth = Self.lineWidth + path.lineCapStyle = .round + + let valueCount = values.count + + guard valueCount > 0, let maxValue = values.max() else { return } + + for (index, value) in values.enumerated() { + let x = CGFloat(index) / CGFloat(valueCount) * rect.width + let y = rect.height - CGFloat(value) / max(CGFloat(maxValue), CGFloat(0).nextUp) * rect.height + let point = CGPoint( + x: min(max(x, Self.lineWidth / 2), rect.width - Self.lineWidth / 2), + y: min(max(y, Self.lineWidth / 2), rect.height - Self.lineWidth / 2)) + + if index > 0 { + path.addLine(to: point) + } + + path.move(to: point) + } + + path.close() + UIColor.link.setStroke() + path.stroke() + } +} + +private extension LineChartView { + static let lineWidth: CGFloat = 2 +} diff --git a/Views/TagContentConfiguration.swift b/Views/TagContentConfiguration.swift new file mode 100644 index 0000000..025cd55 --- /dev/null +++ b/Views/TagContentConfiguration.swift @@ -0,0 +1,18 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +struct TagContentConfiguration { + let viewModel: TagViewModel +} + +extension TagContentConfiguration: UIContentConfiguration { + func makeContentView() -> UIView & UIContentView { + TagView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> TagContentConfiguration { + self + } +} diff --git a/Views/TagTableViewCell.swift b/Views/TagTableViewCell.swift new file mode 100644 index 0000000..1c6f07c --- /dev/null +++ b/Views/TagTableViewCell.swift @@ -0,0 +1,26 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +final class TagTableViewCell: UITableViewCell { + var viewModel: TagViewModel? + + override func updateConfiguration(using state: UICellConfigurationState) { + guard let viewModel = viewModel else { return } + + contentConfiguration = TagContentConfiguration(viewModel: viewModel).updated(for: state) + } + + override func layoutSubviews() { + super.layoutSubviews() + + if UIDevice.current.userInterfaceIdiom == .phone { + separatorInset.left = 0 + separatorInset.right = 0 + } else { + separatorInset.left = layoutMargins.left + separatorInset.right = layoutMargins.right + } + } +} diff --git a/Views/TagView.swift b/Views/TagView.swift new file mode 100644 index 0000000..0722a5f --- /dev/null +++ b/Views/TagView.swift @@ -0,0 +1,113 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Mastodon +import UIKit + +final class TagView: UIView { + private let nameLabel = UILabel() + private let accountsLabel = UILabel() + private let usesLabel = UILabel() + private let lineChartView = LineChartView() + private var tagConfiguration: TagContentConfiguration + + init(configuration: TagContentConfiguration) { + tagConfiguration = configuration + + super.init(frame: .zero) + + initialSetup() + applyTagConfiguration() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension TagView { + static func estimatedHeight(width: CGFloat, tag: Tag) -> CGFloat { + UITableView.automaticDimension + } +} + +extension TagView: UIContentView { + var configuration: UIContentConfiguration { + get { tagConfiguration } + set { + guard let tagConfiguration = newValue as? TagContentConfiguration else { return } + + self.tagConfiguration = tagConfiguration + + applyTagConfiguration() + } + } +} + +private extension TagView { + func initialSetup() { + let stackView = UIStackView() + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = .defaultSpacing + + let verticalStackView = UIStackView() + + stackView.addArrangedSubview(verticalStackView) + verticalStackView.axis = .vertical + verticalStackView.spacing = .compactSpacing + + verticalStackView.addArrangedSubview(nameLabel) + nameLabel.adjustsFontForContentSizeCategory = true + nameLabel.font = .preferredFont(forTextStyle: .headline) + + verticalStackView.addArrangedSubview(accountsLabel) + accountsLabel.adjustsFontForContentSizeCategory = true + accountsLabel.font = .preferredFont(forTextStyle: .subheadline) + accountsLabel.textColor = .secondaryLabel + + stackView.addArrangedSubview(UIView()) + + stackView.addArrangedSubview(usesLabel) + usesLabel.adjustsFontForContentSizeCategory = true + usesLabel.font = .preferredFont(forTextStyle: .largeTitle) + usesLabel.setContentHuggingPriority(.required, for: .vertical) + + stackView.addArrangedSubview(lineChartView) + + 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), + lineChartView.heightAnchor.constraint(equalTo: usesLabel.heightAnchor), + lineChartView.widthAnchor.constraint(equalTo: lineChartView.heightAnchor, multiplier: 16 / 9) + ]) + } + + func applyTagConfiguration() { + let viewModel = tagConfiguration.viewModel + + nameLabel.text = viewModel.name + + if let accounts = viewModel.accounts { + accountsLabel.text = String.localizedStringWithFormat( + NSLocalizedString("tag.people-talking", comment: ""), + accounts) + accountsLabel.isHidden = false + } else { + accountsLabel.isHidden = true + } + + if let uses = viewModel.uses { + usesLabel.text = String(uses) + usesLabel.isHidden = false + } else { + usesLabel.isHidden = true + } + + lineChartView.values = viewModel.usageHistory.reversed() + lineChartView.isHidden = viewModel.usageHistory.isEmpty + } +}