mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 01:31:02 +00:00
Trends in explore
This commit is contained in:
parent
72c08642d8
commit
ea366368c8
19 changed files with 554 additions and 3 deletions
|
@ -202,6 +202,24 @@ extension ContentDatabase {
|
|||
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
|
||||
t.column("parentId", .text).indexed().notNull()
|
||||
.references("statusRecord", onDelete: .cascade)
|
||||
|
|
|
@ -439,6 +439,12 @@ public extension ContentDatabase {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func insert(instance: Instance) -> AnyPublisher<Never, Error> {
|
||||
databaseWriter.writePublisher(updates: instance.save)
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[CollectionSection], Error> {
|
||||
ValueObservation.tracking(
|
||||
TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne)
|
||||
|
@ -611,6 +617,16 @@ public extension ContentDatabase {
|
|||
.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> {
|
||||
ValueObservation.tracking(
|
||||
Emoji.filter(Emoji.Columns.visibleInPicker == true)
|
||||
|
|
20
DB/Sources/DB/Content/InstanceInfo.swift
Normal file
20
DB/Sources/DB/Content/InstanceInfo.swift
Normal 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)
|
||||
}
|
||||
}
|
63
DB/Sources/DB/Content/InstanceRecord.swift
Normal file
63
DB/Sources/DB/Content/InstanceRecord.swift
Normal 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
|
||||
}
|
||||
}
|
41
DB/Sources/DB/Extensions/Instance+Extensions.swift
Normal file
41
DB/Sources/DB/Extensions/Instance+Extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
70
Data Sources/ExploreDataSource.swift
Normal file
70
Data Sources/ExploreDataSource.swift
Normal 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: "")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -74,6 +74,8 @@
|
|||
"emoji.system-group.objects" = "Objects";
|
||||
"emoji.system-group.symbols" = "Symbols";
|
||||
"emoji.system-group.flags" = "Flags";
|
||||
"explore.trending" = "Trending Now";
|
||||
"explore.instance" = "Instance";
|
||||
"error" = "Error";
|
||||
"favorites" = "Favorites";
|
||||
"follow-requests" = "Follow Requests";
|
||||
|
|
|
@ -28,4 +28,28 @@ public struct Instance: Codable, Hashable {
|
|||
public let thumbnail: URL?
|
||||
public let contactAccount: Account?
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
25
MastodonAPI/Sources/MastodonAPI/Endpoints/TagsEndpoint.swift
Normal file
25
MastodonAPI/Sources/MastodonAPI/Endpoints/TagsEndpoint.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -145,6 +145,9 @@
|
|||
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 */; };
|
||||
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 */; };
|
||||
D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; };
|
||||
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>"; };
|
||||
D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; 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>"; };
|
||||
D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebfingerIndicatorView.swift; sourceTree = "<group>"; };
|
||||
D0E2C1CF24FD8BA400854680 /* ViewModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ViewModels; sourceTree = "<group>"; };
|
||||
|
@ -415,6 +421,7 @@
|
|||
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
|
||||
D05936DD25A937EC00754FDF /* EditThumbnailView.swift */,
|
||||
D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */,
|
||||
D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */,
|
||||
D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */,
|
||||
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */,
|
||||
D05936FE25AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift */,
|
||||
|
@ -485,6 +492,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
D07EC7DB25B13DBB006DF726 /* EmojiCollectionViewCell.swift */,
|
||||
D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */,
|
||||
);
|
||||
path = "Collection View Cells";
|
||||
sourceTree = "<group>";
|
||||
|
@ -610,6 +618,7 @@
|
|||
D0A1F4F5252E7D2A004435BF /* Data Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */,
|
||||
D021A5FF25C3478F008A0C0D /* IdentitiesDataSource.swift */,
|
||||
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */,
|
||||
);
|
||||
|
@ -934,6 +943,7 @@
|
|||
D036AA07254B6118009094DF /* NotificationView.swift in Sources */,
|
||||
D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */,
|
||||
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */,
|
||||
D0DDA77525C5F73F00FA0F91 /* TagCollectionViewCell.swift in Sources */,
|
||||
D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */,
|
||||
D05936DE25A937EC00754FDF /* EditThumbnailView.swift in Sources */,
|
||||
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */,
|
||||
|
@ -1021,6 +1031,7 @@
|
|||
D025B17E25C500BC001C69A8 /* CapsuleButton.swift in Sources */,
|
||||
D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */,
|
||||
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */,
|
||||
D0DDA77F25C6058300FA0F91 /* ExploreSectionHeaderView.swift in Sources */,
|
||||
D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */,
|
||||
D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */,
|
||||
D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */,
|
||||
|
@ -1029,6 +1040,7 @@
|
|||
D097F4C125BFA04C00859F2C /* NotificationsViewController.swift in Sources */,
|
||||
D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */,
|
||||
D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */,
|
||||
D0DDA76B25C5F20800FA0F91 /* ExploreDataSource.swift in Sources */,
|
||||
D035F88725B8016000DC75ED /* NavigationViewModel+Extensions.swift in Sources */,
|
||||
D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */,
|
||||
D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */,
|
||||
|
|
|
@ -7,12 +7,15 @@ import Mastodon
|
|||
import MastodonAPI
|
||||
|
||||
public struct ExploreService {
|
||||
public let navigationService: NavigationService
|
||||
|
||||
private let mastodonAPIClient: MastodonAPIClient
|
||||
private let contentDatabase: ContentDatabase
|
||||
|
||||
init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
||||
self.mastodonAPIClient = mastodonAPIClient
|
||||
self.contentDatabase = contentDatabase
|
||||
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -20,4 +23,14 @@ public extension ExploreService {
|
|||
func searchService() -> SearchService {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,7 +64,10 @@ public extension IdentityService {
|
|||
|
||||
func refreshInstance() -> AnyPublisher<Never, Error> {
|
||||
mastodonAPIClient.request(InstanceEndpoint.instance)
|
||||
.flatMap { identityDatabase.updateInstance($0, id: id) }
|
||||
.flatMap {
|
||||
identityDatabase.updateInstance($0, id: id)
|
||||
.merge(with: contentDatabase.insert(instance: $0))
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -9,11 +9,15 @@ final class ExploreViewController: UICollectionViewController {
|
|||
private let rootViewModel: RootViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private lazy var dataSource: ExploreDataSource = {
|
||||
.init(collectionView: collectionView, viewModel: viewModel)
|
||||
}()
|
||||
|
||||
init(viewModel: ExploreViewModel, rootViewModel: RootViewModel) {
|
||||
self.viewModel = viewModel
|
||||
self.rootViewModel = rootViewModel
|
||||
|
||||
super.init(collectionViewLayout: UICollectionViewFlowLayout())
|
||||
super.init(collectionViewLayout: Self.layout())
|
||||
|
||||
tabBarItem = UITabBarItem(
|
||||
title: NSLocalizedString("main-navigation.explore", comment: ""),
|
||||
|
@ -29,6 +33,16 @@ final class ExploreViewController: UICollectionViewController {
|
|||
override func 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: "")
|
||||
|
||||
let searchResultsController = TableViewController(
|
||||
|
@ -43,6 +57,19 @@ final class ExploreViewController: UICollectionViewController {
|
|||
searchController.searchResultsUpdater = self
|
||||
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
|
||||
if case let .navigation(navigation) = $0,
|
||||
case let .searchScope(scope) = navigation {
|
||||
|
@ -52,6 +79,18 @@ final class ExploreViewController: UICollectionViewController {
|
|||
}
|
||||
.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 {
|
||||
|
@ -68,3 +107,36 @@ extension ExploreViewController: UISearchResultsUpdating {
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,22 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Mastodon
|
||||
import ServiceLayer
|
||||
|
||||
public final class ExploreViewModel: ObservableObject {
|
||||
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
|
||||
|
||||
private let exploreService: ExploreService
|
||||
private let eventsSubject = PassthroughSubject<Event, Never>()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(service: ExploreService, identityContext: IdentityContext) {
|
||||
exploreService = service
|
||||
|
@ -15,5 +24,60 @@ public final class ExploreViewModel: ObservableObject {
|
|||
searchViewModel = SearchViewModel(
|
||||
searchService: exploreService.searchService(),
|
||||
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)))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -121,7 +121,7 @@ public extension NavigationViewModel {
|
|||
service: identityContext.service.exploreService(),
|
||||
identityContext: identityContext)
|
||||
|
||||
// TODO: initial request
|
||||
exploreViewModel.refresh()
|
||||
|
||||
return exploreViewModel
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
])
|
||||
}
|
||||
}
|
44
Views/UIKit/ExploreSectionHeaderView.swift
Normal file
44
Views/UIKit/ExploreSectionHeaderView.swift
Normal 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)
|
||||
])
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue