Trends in explore

This commit is contained in:
Justin Mazzocchi 2021-01-30 14:27:49 -08:00
parent 72c08642d8
commit ea366368c8
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
19 changed files with 554 additions and 3 deletions

View file

@ -202,6 +202,24 @@ extension ContentDatabase {
t.column("statusId").references("statusRecord", onDelete: .cascade) t.column("statusId").references("statusRecord", onDelete: .cascade)
} }
try db.create(table: "instanceRecord") { t in
t.column("uri", .text).primaryKey(onConflict: .replace)
t.column("title", .text).notNull()
t.column("description", .text).notNull()
t.column("shortDescription", .text)
t.column("email", .text).notNull()
t.column("version", .text).notNull()
t.column("languages", .blob).notNull()
t.column("registrations", .boolean).notNull()
t.column("approvalRequired", .boolean).notNull()
t.column("invitesEnabled", .boolean).notNull()
t.column("urls", .blob).notNull()
t.column("stats", .blob).notNull()
t.column("thumbnail", .text)
t.column("contactAccountId", .text).references("accountRecord", onDelete: .cascade)
t.column("maxTootChars", .integer)
}
try db.create(table: "statusAncestorJoin") { t in try db.create(table: "statusAncestorJoin") { t in
t.column("parentId", .text).indexed().notNull() t.column("parentId", .text).indexed().notNull()
.references("statusRecord", onDelete: .cascade) .references("statusRecord", onDelete: .cascade)

View file

@ -439,6 +439,12 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func insert(instance: Instance) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher(updates: instance.save)
.ignoreOutput()
.eraseToAnyPublisher()
}
func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[CollectionSection], Error> { func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[CollectionSection], Error> {
ValueObservation.tracking( ValueObservation.tracking(
TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne)
@ -611,6 +617,16 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func instancePublisher(uri: String) -> AnyPublisher<Instance, Error> {
ValueObservation.tracking(
InstanceInfo.request(InstanceRecord.filter(InstanceRecord.Columns.uri == uri)).fetchOne)
.removeDuplicates()
.publisher(in: databaseWriter)
.compactMap { $0 }
.map(Instance.init(info:))
.eraseToAnyPublisher()
}
func pickerEmojisPublisher() -> AnyPublisher<[Emoji], Error> { func pickerEmojisPublisher() -> AnyPublisher<[Emoji], Error> {
ValueObservation.tracking( ValueObservation.tracking(
Emoji.filter(Emoji.Columns.visibleInPicker == true) Emoji.filter(Emoji.Columns.visibleInPicker == true)

View file

@ -0,0 +1,20 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
struct InstanceInfo: Codable, Hashable, FetchableRecord {
let record: InstanceRecord
let contactAccountInfo: AccountInfo?
}
extension InstanceInfo {
static func addingIncludes<T: DerivableRequest>(_ request: T) -> T where T.RowDecoder == InstanceRecord {
request.including(optional: AccountInfo.addingIncludes(InstanceRecord.contactAccount)
.forKey(CodingKeys.contactAccountInfo))
}
static func request(_ request: QueryInterfaceRequest<InstanceRecord>) -> QueryInterfaceRequest<Self> {
addingIncludes(request).asRequest(of: self)
}
}

View file

@ -0,0 +1,63 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
import Mastodon
struct InstanceRecord: ContentDatabaseRecord, Hashable {
let uri: String
let title: String
let description: String
let shortDescription: String?
let email: String
let version: String
let languages: [String]
let registrations: Bool
let approvalRequired: Bool
let invitesEnabled: Bool
let urls: Instance.URLs
let stats: Instance.Stats
let thumbnail: URL?
let contactAccountId: Account.Id?
let maxTootChars: Int?
}
extension InstanceRecord {
enum Columns {
static let uri = Column(CodingKeys.uri)
static let title = Column(CodingKeys.title)
static let description = Column(CodingKeys.description)
static let shortDescription = Column(CodingKeys.shortDescription)
static let email = Column(CodingKeys.email)
static let version = Column(CodingKeys.version)
static let languages = Column(CodingKeys.languages)
static let registrations = Column(CodingKeys.registrations)
static let approvalRequired = Column(CodingKeys.approvalRequired)
static let invitesEnabled = Column(CodingKeys.invitesEnabled)
static let urls = Column(CodingKeys.urls)
static let stats = Column(CodingKeys.stats)
static let thumbnail = Column(CodingKeys.thumbnail)
static let contactAccountId = Column(CodingKeys.contactAccountId)
static let maxTootChars = Column(CodingKeys.maxTootChars)
}
static let contactAccount = belongsTo(AccountRecord.self)
init(instance: Instance) {
self.uri = instance.uri
self.title = instance.title
self.description = instance.description
self.shortDescription = instance.shortDescription
self.email = instance.email
self.version = instance.version
self.languages = instance.languages
self.registrations = instance.registrations
self.approvalRequired = instance.approvalRequired
self.invitesEnabled = instance.invitesEnabled
self.urls = instance.urls
self.stats = instance.stats
self.thumbnail = instance.thumbnail
self.contactAccountId = instance.contactAccount?.id
self.maxTootChars = instance.maxTootChars
}
}

View file

@ -0,0 +1,41 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
import Mastodon
extension Instance {
func save(_ db: Database) throws {
if let contactAccount = contactAccount {
try AccountRecord(account: contactAccount).save(db)
}
try InstanceRecord(instance: self).save(db)
}
init(info: InstanceInfo) {
var contactAccount: Account?
if let contactAccountInfo = info.contactAccountInfo {
contactAccount = Account(info: contactAccountInfo)
}
self.init(record: info.record, contactAccount: contactAccount)
}
}
private extension Instance {
init(record: InstanceRecord, contactAccount: Account?) {
self.init(uri: record.uri,
title: record.title,
description: record.description,
shortDescription: record.shortDescription,
email: record.email,
version: record.version,
urls: record.urls,
stats: record.stats,
thumbnail: record.thumbnail,
contactAccount: contactAccount,
maxTootChars: record.maxTootChars)
}
}

View file

@ -0,0 +1,70 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Combine
import Mastodon
import UIKit
import ViewModels
final class ExploreDataSource: UICollectionViewDiffableDataSource<ExploreViewModel.Section, ExploreViewModel.Item> {
private let updateQueue =
DispatchQueue(label: "com.metabolist.metatext.explore-data-source.update-queue")
private var cancellables = Set<AnyCancellable>()
init(collectionView: UICollectionView, viewModel: ExploreViewModel) {
let tagRegistration = UICollectionView.CellRegistration<TagCollectionViewCell, TagViewModel> {
$0.viewModel = $2
}
super.init(collectionView: collectionView) {
switch $2 {
case let .tag(tag):
return $0.dequeueConfiguredReusableCell(
using: tagRegistration,
for: $1,
item: viewModel.viewModel(tag: tag))
}
}
let headerRegistration = UICollectionView.SupplementaryRegistration
<ExploreSectionHeaderView>(elementKind: "Header") { [weak self] in
$0.label.text = self?.snapshot().sectionIdentifiers[$2.section].displayName
}
supplementaryViewProvider = {
$0.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: $2)
}
viewModel.$trends.sink { [weak self] tags in
guard let self = self else { return }
var snapshot = NSDiffableDataSourceSnapshot<ExploreViewModel.Section, ExploreViewModel.Item>()
if !tags.isEmpty {
snapshot.appendSections([.trending])
snapshot.appendItems(tags.map(ExploreViewModel.Item.tag), toSection: .trending)
}
self.apply(snapshot, animatingDifferences: false)
}
.store(in: &cancellables)
}
override func apply(_ snapshot: NSDiffableDataSourceSnapshot<ExploreViewModel.Section, ExploreViewModel.Item>,
animatingDifferences: Bool = true,
completion: (() -> Void)? = nil) {
updateQueue.async {
super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion)
}
}
}
private extension ExploreViewModel.Section {
var displayName: String {
switch self {
case .trending:
return NSLocalizedString("explore.trending", comment: "")
case .instance:
return NSLocalizedString("explore.instance", comment: "")
}
}
}

View file

@ -74,6 +74,8 @@
"emoji.system-group.objects" = "Objects"; "emoji.system-group.objects" = "Objects";
"emoji.system-group.symbols" = "Symbols"; "emoji.system-group.symbols" = "Symbols";
"emoji.system-group.flags" = "Flags"; "emoji.system-group.flags" = "Flags";
"explore.trending" = "Trending Now";
"explore.instance" = "Instance";
"error" = "Error"; "error" = "Error";
"favorites" = "Favorites"; "favorites" = "Favorites";
"follow-requests" = "Follow Requests"; "follow-requests" = "Follow Requests";

View file

@ -28,4 +28,28 @@ public struct Instance: Codable, Hashable {
public let thumbnail: URL? public let thumbnail: URL?
public let contactAccount: Account? public let contactAccount: Account?
public let maxTootChars: Int? public let maxTootChars: Int?
public init(uri: String,
title: String,
description: String,
shortDescription: String?,
email: String,
version: String,
urls: Instance.URLs,
stats: Instance.Stats,
thumbnail: URL?,
contactAccount: Account?,
maxTootChars: Int?) {
self.uri = uri
self.title = title
self.description = description
self.shortDescription = shortDescription
self.email = email
self.version = version
self.urls = urls
self.stats = stats
self.thumbnail = thumbnail
self.contactAccount = contactAccount
self.maxTootChars = maxTootChars
}
} }

View file

@ -0,0 +1,25 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import HTTP
import Mastodon
public enum TagsEndpoint {
case trends
}
extension TagsEndpoint: Endpoint {
public typealias ResultType = [Tag]
public var pathComponentsInContext: [String] {
switch self {
case .trends: return ["trends"]
}
}
public var method: HTTPMethod {
switch self {
case .trends: return .get
}
}
}

View file

@ -145,6 +145,9 @@
D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC5225BCD2BA003D5DF2 /* TagContentConfiguration.swift */; }; D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC5225BCD2BA003D5DF2 /* TagContentConfiguration.swift */; };
D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */; }; D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */; };
D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DD50CA256B1F24004A04F7 /* ReportView.swift */; }; D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DD50CA256B1F24004A04F7 /* ReportView.swift */; };
D0DDA76B25C5F20800FA0F91 /* ExploreDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */; };
D0DDA77525C5F73F00FA0F91 /* TagCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */; };
D0DDA77F25C6058300FA0F91 /* ExploreSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */; };
D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */; }; D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */; };
D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; }; D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; };
D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; }; D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; };
@ -332,6 +335,9 @@
D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = "<group>"; }; D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = "<group>"; };
D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = "<group>"; }; D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = "<group>"; };
D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; }; D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; };
D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreDataSource.swift; sourceTree = "<group>"; };
D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectionViewCell.swift; sourceTree = "<group>"; };
D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreSectionHeaderView.swift; sourceTree = "<group>"; };
D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = "<group>"; }; D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = "<group>"; };
D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = "<group>"; }; D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = "<group>"; };
D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = "<group>"; }; D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = "<group>"; };
@ -415,6 +421,7 @@
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */, D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
D05936DD25A937EC00754FDF /* EditThumbnailView.swift */, D05936DD25A937EC00754FDF /* EditThumbnailView.swift */,
D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */, D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */,
D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */,
D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */, D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */,
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */,
D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */, D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */,
@ -485,6 +492,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */, D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */,
D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */,
); );
path = "Collection View Cells"; path = "Collection View Cells";
sourceTree = "<group>"; sourceTree = "<group>";
@ -610,6 +618,7 @@
D0A1F4F5252E7D2A004435BF /* Data Sources */ = { D0A1F4F5252E7D2A004435BF /* Data Sources */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */,
D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */, D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */,
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */, D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */,
); );
@ -934,6 +943,7 @@
D036AA07254B6118009094DF /* NotificationView.swift in Sources */, D036AA07254B6118009094DF /* NotificationView.swift in Sources */,
D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */, D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */,
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */,
D0DDA77525C5F73F00FA0F91 /* TagCollectionViewCell.swift in Sources */,
D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */,
D05936DE25A937EC00754FDF /* EditThumbnailView.swift in Sources */, D05936DE25A937EC00754FDF /* EditThumbnailView.swift in Sources */,
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */,
@ -1021,6 +1031,7 @@
D025B17E25C500BC001C69A8 /* CapsuleButton.swift in Sources */, D025B17E25C500BC001C69A8 /* CapsuleButton.swift in Sources */,
D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */, D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */,
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */, D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */,
D0DDA77F25C6058300FA0F91 /* ExploreSectionHeaderView.swift in Sources */,
D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */, D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */,
D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */, D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */,
D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */, D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */,
@ -1029,6 +1040,7 @@
D097F4C125BFA04C00859F2C /* NotificationsViewController.swift in Sources */, D097F4C125BFA04C00859F2C /* NotificationsViewController.swift in Sources */,
D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */, D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */,
D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */, D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */,
D0DDA76B25C5F20800FA0F91 /* ExploreDataSource.swift in Sources */,
D035F88725B8016000DC75ED /* NavigationViewModel+Extensions.swift in Sources */, D035F88725B8016000DC75ED /* NavigationViewModel+Extensions.swift in Sources */,
D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */, D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */,
D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */, D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */,

View file

@ -7,12 +7,15 @@ import Mastodon
import MastodonAPI import MastodonAPI
public struct ExploreService { public struct ExploreService {
public let navigationService: NavigationService
private let mastodonAPIClient: MastodonAPIClient private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase private let contentDatabase: ContentDatabase
init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.mastodonAPIClient = mastodonAPIClient self.mastodonAPIClient = mastodonAPIClient
self.contentDatabase = contentDatabase self.contentDatabase = contentDatabase
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
} }
} }
@ -20,4 +23,14 @@ public extension ExploreService {
func searchService() -> SearchService { func searchService() -> SearchService {
SearchService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) SearchService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
} }
func instanceServicePublisher(uri: String) -> AnyPublisher<InstanceService, Error> {
contentDatabase.instancePublisher(uri: uri)
.map { InstanceService(instance: $0, mastodonAPIClient: mastodonAPIClient) }
.eraseToAnyPublisher()
}
func fetchTrends() -> AnyPublisher<[Tag], Error> {
mastodonAPIClient.request(TagsEndpoint.trends)
}
} }

View file

@ -64,7 +64,10 @@ public extension IdentityService {
func refreshInstance() -> AnyPublisher<Never, Error> { func refreshInstance() -> AnyPublisher<Never, Error> {
mastodonAPIClient.request(InstanceEndpoint.instance) mastodonAPIClient.request(InstanceEndpoint.instance)
.flatMap { identityDatabase.updateInstance($0, id: id) } .flatMap {
identityDatabase.updateInstance($0, id: id)
.merge(with: contentDatabase.insert(instance: $0))
}
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View file

@ -0,0 +1,17 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Combine
import Foundation
import Mastodon
import MastodonAPI
public struct InstanceService {
public let instance: Instance
private let mastodonAPIClient: MastodonAPIClient
init(instance: Instance, mastodonAPIClient: MastodonAPIClient) {
self.instance = instance
self.mastodonAPIClient = mastodonAPIClient
}
}

View file

@ -9,11 +9,15 @@ final class ExploreViewController: UICollectionViewController {
private let rootViewModel: RootViewModel private let rootViewModel: RootViewModel
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private lazy var dataSource: ExploreDataSource = {
.init(collectionView: collectionView, viewModel: viewModel)
}()
init(viewModel: ExploreViewModel, rootViewModel: RootViewModel) { init(viewModel: ExploreViewModel, rootViewModel: RootViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
self.rootViewModel = rootViewModel self.rootViewModel = rootViewModel
super.init(collectionViewLayout: UICollectionViewFlowLayout()) super.init(collectionViewLayout: Self.layout())
tabBarItem = UITabBarItem( tabBarItem = UITabBarItem(
title: NSLocalizedString("main-navigation.explore", comment: ""), title: NSLocalizedString("main-navigation.explore", comment: ""),
@ -29,6 +33,16 @@ final class ExploreViewController: UICollectionViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
collectionView.dataSource = dataSource
collectionView.backgroundColor = .systemBackground
clearsSelectionOnViewWillAppear = true
collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl?.addAction(
UIAction { [weak self] _ in
self?.viewModel.refresh() },
for: .valueChanged)
navigationItem.title = NSLocalizedString("main-navigation.explore", comment: "") navigationItem.title = NSLocalizedString("main-navigation.explore", comment: "")
let searchResultsController = TableViewController( let searchResultsController = TableViewController(
@ -43,6 +57,19 @@ final class ExploreViewController: UICollectionViewController {
searchController.searchResultsUpdater = self searchController.searchResultsUpdater = self
navigationItem.searchController = searchController navigationItem.searchController = searchController
viewModel.events.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables)
viewModel.$loading.sink { [weak self] in
guard let self = self else { return }
let refreshControlVisibile = self.collectionView.refreshControl?.isRefreshing ?? false
if !$0, refreshControlVisibile {
self.collectionView.refreshControl?.endRefreshing()
}
}
.store(in: &cancellables)
viewModel.searchViewModel.events.sink { [weak self] in viewModel.searchViewModel.events.sink { [weak self] in
if case let .navigation(navigation) = $0, if case let .navigation(navigation) = $0,
case let .searchScope(scope) = navigation { case let .searchScope(scope) = navigation {
@ -52,6 +79,18 @@ final class ExploreViewController: UICollectionViewController {
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.refresh()
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
viewModel.select(item: item)
}
} }
extension ExploreViewController: UISearchResultsUpdating { extension ExploreViewController: UISearchResultsUpdating {
@ -68,3 +107,36 @@ extension ExploreViewController: UISearchResultsUpdating {
viewModel.searchViewModel.query = searchController.searchBar.text ?? "" viewModel.searchViewModel.query = searchController.searchBar.text ?? ""
} }
} }
private extension ExploreViewController {
static func layout() -> UICollectionViewLayout {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.headerMode = .supplementary
return UICollectionViewCompositionalLayout.list(using: config)
}
func handle(event: ExploreViewModel.Event) {
switch event {
case let .navigation(navigation):
handle(navigation: navigation)
}
}
func handle(navigation: Navigation) {
switch navigation {
case let .collection(collectionService):
let vc = TableViewController(
viewModel: CollectionItemsViewModel(
collectionService: collectionService,
identityContext: viewModel.identityContext),
rootViewModel: rootViewModel,
parentNavigationController: nil)
show(vc, sender: self)
default:
break
}
}
}

View file

@ -1,13 +1,22 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation import Foundation
import Mastodon
import ServiceLayer import ServiceLayer
public final class ExploreViewModel: ObservableObject { public final class ExploreViewModel: ObservableObject {
public let searchViewModel: SearchViewModel public let searchViewModel: SearchViewModel
public let events: AnyPublisher<Event, Never>
@Published public var instanceViewModel: InstanceViewModel?
@Published public var trends = [Tag]()
@Published public private(set) var loading = false
@Published public var alertItem: AlertItem?
public let identityContext: IdentityContext public let identityContext: IdentityContext
private let exploreService: ExploreService private let exploreService: ExploreService
private let eventsSubject = PassthroughSubject<Event, Never>()
private var cancellables = Set<AnyCancellable>()
init(service: ExploreService, identityContext: IdentityContext) { init(service: ExploreService, identityContext: IdentityContext) {
exploreService = service exploreService = service
@ -15,5 +24,60 @@ public final class ExploreViewModel: ObservableObject {
searchViewModel = SearchViewModel( searchViewModel = SearchViewModel(
searchService: exploreService.searchService(), searchService: exploreService.searchService(),
identityContext: identityContext) identityContext: identityContext)
events = eventsSubject.eraseToAnyPublisher()
identityContext.$identity
.compactMap { $0.instance?.uri }
.removeDuplicates()
.flatMap { service.instanceServicePublisher(uri: $0) }
.map { InstanceViewModel(instanceService: $0) }
.receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$instanceViewModel)
}
}
public extension ExploreViewModel {
enum Event {
case navigation(Navigation)
}
enum Section: Hashable {
case trending
case instance
}
enum Item: Hashable {
case tag(Tag)
}
func refresh() {
exploreService.fetchTrends()
.handleEvents(receiveOutput: { [weak self] trends in
DispatchQueue.main.async {
self?.trends = trends
}
})
.ignoreOutput()
.merge(with: identityContext.service.refreshInstance())
.receive(on: DispatchQueue.main)
.handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true },
receiveCompletion: { [weak self] _ in self?.loading = false })
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
}
func viewModel(tag: Tag) -> TagViewModel {
.init(tag: tag)
}
func select(item: ExploreViewModel.Item) {
switch item {
case let .tag(tag):
eventsSubject.send(
.navigation(.collection(exploreService
.navigationService
.timelineService(timeline: .tag(tag.name)))))
}
} }
} }

View file

@ -0,0 +1,12 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Foundation
import ServiceLayer
public final class InstanceViewModel: ObservableObject {
private let instanceService: InstanceService
init(instanceService: InstanceService) {
self.instanceService = instanceService
}
}

View file

@ -121,7 +121,7 @@ public extension NavigationViewModel {
service: identityContext.service.exploreService(), service: identityContext.service.exploreService(),
identityContext: identityContext) identityContext: identityContext)
// TODO: initial request exploreViewModel.refresh()
return exploreViewModel return exploreViewModel
} }

View file

@ -0,0 +1,35 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
import ViewModels
final class TagCollectionViewCell: UICollectionViewListCell {
var viewModel: TagViewModel?
override func updateConfiguration(using state: UICellConfigurationState) {
guard let viewModel = viewModel else { return }
contentConfiguration = TagContentConfiguration(viewModel: viewModel).updated(for: state)
updateConstraintsIfNeeded()
}
override func updateConstraints() {
super.updateConstraints()
let separatorLeadingAnchor: NSLayoutXAxisAnchor
let separatorTrailingAnchor: NSLayoutXAxisAnchor
if UIDevice.current.userInterfaceIdiom == .pad {
separatorLeadingAnchor = readableContentGuide.leadingAnchor
separatorTrailingAnchor = readableContentGuide.trailingAnchor
} else {
separatorLeadingAnchor = leadingAnchor
separatorTrailingAnchor = trailingAnchor
}
NSLayoutConstraint.activate([
separatorLayoutGuide.leadingAnchor.constraint(equalTo: separatorLeadingAnchor),
separatorLayoutGuide.trailingAnchor.constraint(equalTo: separatorTrailingAnchor)
])
}
}

View file

@ -0,0 +1,44 @@
// Copyright © 2021 Metabolist. All rights reserved.
import UIKit
final class ExploreSectionHeaderView: UICollectionReusableView {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension ExploreSectionHeaderView {
func initialSetup() {
backgroundColor = .systemBackground
addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.adjustsFontForContentSizeCategory = true
label.font = .preferredFont(forTextStyle: .headline)
let layoutGuide: UILayoutGuide
if UIDevice.current.userInterfaceIdiom == .pad {
layoutGuide = readableContentGuide
} else {
layoutGuide = layoutMarginsGuide
}
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor),
label.topAnchor.constraint(equalTo: layoutGuide.topAnchor),
label.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor),
label.bottomAnchor.constraint(equalTo: layoutGuide.bottomAnchor)
])
}
}