mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 01:31:02 +00:00
Search wip
This commit is contained in:
parent
7c17618065
commit
c4da421846
27 changed files with 357 additions and 49 deletions
|
@ -425,6 +425,38 @@ public extension ContentDatabase {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func process(results: Results) -> AnyPublisher<[[CollectionItem]], Error> {
|
||||||
|
databaseWriter.writePublisher { db -> ([StatusInfo], [Status.Id]) in
|
||||||
|
for account in results.accounts {
|
||||||
|
try account.save(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
for status in results.statuses {
|
||||||
|
try status.save(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = results.statuses.map(\.id)
|
||||||
|
let statusInfos = try StatusInfo.request(
|
||||||
|
StatusRecord.filter(ids.contains(StatusRecord.Columns.id)))
|
||||||
|
.fetchAll(db)
|
||||||
|
|
||||||
|
return (statusInfos, ids)
|
||||||
|
}
|
||||||
|
.map { statusInfos, ids -> [[CollectionItem]] in
|
||||||
|
[
|
||||||
|
results.accounts.map(CollectionItem.account),
|
||||||
|
statusInfos
|
||||||
|
.sorted { ids.firstIndex(of: $0.record.id) ?? 0 < ids.firstIndex(of: $1.record.id) ?? 0 }
|
||||||
|
.map {
|
||||||
|
.status(.init(info: $0),
|
||||||
|
.init(showContentToggled: $0.showContentToggled,
|
||||||
|
showAttachmentsToggled: $0.showAttachmentsToggled))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> {
|
func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], 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)
|
||||||
|
|
|
@ -5,7 +5,43 @@ import HTTP
|
||||||
import Mastodon
|
import Mastodon
|
||||||
|
|
||||||
public enum ResultsEndpoint {
|
public enum ResultsEndpoint {
|
||||||
case search(query: String, resolve: Bool)
|
case search(Search)
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ResultsEndpoint {
|
||||||
|
struct Search {
|
||||||
|
public let query: String
|
||||||
|
public let type: SearchType?
|
||||||
|
public let excludeUnreviewed: Bool
|
||||||
|
public let resolve: Bool
|
||||||
|
public let limit: Int?
|
||||||
|
public let offset: Int?
|
||||||
|
public let following: Bool
|
||||||
|
|
||||||
|
public init(query: String,
|
||||||
|
type: SearchType? = nil,
|
||||||
|
excludeUnreviewed: Bool = false,
|
||||||
|
resolve: Bool = false,
|
||||||
|
limit: Int? = nil,
|
||||||
|
offset: Int? = nil,
|
||||||
|
following: Bool = false) {
|
||||||
|
self.query = query
|
||||||
|
self.type = type
|
||||||
|
self.excludeUnreviewed = excludeUnreviewed
|
||||||
|
self.resolve = resolve
|
||||||
|
self.limit = limit
|
||||||
|
self.offset = offset
|
||||||
|
self.following = following
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ResultsEndpoint.Search {
|
||||||
|
enum SearchType: String {
|
||||||
|
case accounts
|
||||||
|
case hashtags
|
||||||
|
case statuses
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ResultsEndpoint: Endpoint {
|
extension ResultsEndpoint: Endpoint {
|
||||||
|
@ -34,13 +70,33 @@ extension ResultsEndpoint: Endpoint {
|
||||||
|
|
||||||
public var queryParameters: [URLQueryItem] {
|
public var queryParameters: [URLQueryItem] {
|
||||||
switch self {
|
switch self {
|
||||||
case let .search(query, resolve):
|
case let .search(search):
|
||||||
var params = [URLQueryItem(name: "q", value: query)]
|
var params = [URLQueryItem(name: "q", value: search.query)]
|
||||||
|
|
||||||
if resolve {
|
if let type = search.type {
|
||||||
|
params.append(.init(name: "type", value: type.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
if search.excludeUnreviewed {
|
||||||
|
params.append(.init(name: "exclude_unreviewed", value: "true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if search.resolve {
|
||||||
params.append(.init(name: "resolve", value: "true"))
|
params.append(.init(name: "resolve", value: "true"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let limit = search.limit {
|
||||||
|
params.append(.init(name: "limit", value: String(limit)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let offset = search.offset {
|
||||||
|
params.append(.init(name: "offset", value: String(offset)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if search.following {
|
||||||
|
params.append(.init(name: "following", value: "true"))
|
||||||
|
}
|
||||||
|
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,6 +74,7 @@
|
||||||
D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; };
|
D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; };
|
||||||
D07EC81225B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; };
|
D07EC81225B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; };
|
||||||
D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */; };
|
D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */; };
|
||||||
|
D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D087671525BAA8C0001FDD43 /* ExploreViewController.swift */; };
|
||||||
D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
|
D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
|
||||||
D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
|
D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
|
||||||
D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; };
|
D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; };
|
||||||
|
@ -244,6 +245,7 @@
|
||||||
D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemEmoji+Extensions.swift"; sourceTree = "<group>"; };
|
D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemEmoji+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Extensions.swift"; sourceTree = "<group>"; };
|
D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
|
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
|
||||||
|
D087671525BAA8C0001FDD43 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = "<group>"; };
|
||||||
D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerViewController.swift; sourceTree = "<group>"; };
|
D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerViewController.swift; sourceTree = "<group>"; };
|
||||||
D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = "<group>"; };
|
D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = "<group>"; };
|
||||||
D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = "<group>"; };
|
D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -549,6 +551,7 @@
|
||||||
children = (
|
children = (
|
||||||
D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */,
|
D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */,
|
||||||
D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */,
|
D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */,
|
||||||
|
D087671525BAA8C0001FDD43 /* ExploreViewController.swift */,
|
||||||
D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */,
|
D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */,
|
||||||
D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */,
|
D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */,
|
||||||
D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */,
|
D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */,
|
||||||
|
@ -916,6 +919,7 @@
|
||||||
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */,
|
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */,
|
||||||
D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */,
|
D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */,
|
||||||
D0F2D54B2581CF7D00986197 /* VisualEffectBlur.swift in Sources */,
|
D0F2D54B2581CF7D00986197 /* VisualEffectBlur.swift in Sources */,
|
||||||
|
D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */,
|
||||||
D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */,
|
D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */,
|
||||||
D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */,
|
D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */,
|
||||||
D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */,
|
D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */,
|
||||||
|
|
5
ServiceLayer/Sources/ServiceLayer/Entities/Search.swift
Normal file
5
ServiceLayer/Sources/ServiceLayer/Entities/Search.swift
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
// Copyright © 2021 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import MastodonAPI
|
||||||
|
|
||||||
|
public typealias Search = ResultsEndpoint.Search
|
|
@ -34,7 +34,7 @@ public struct AccountListService {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AccountListService: CollectionService {
|
extension AccountListService: CollectionService {
|
||||||
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> {
|
public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
|
||||||
mastodonAPIClient.pagedRequest(endpoint, maxId: maxId, minId: minId)
|
mastodonAPIClient.pagedRequest(endpoint, maxId: maxId, minId: minId)
|
||||||
.handleEvents(receiveOutput: {
|
.handleEvents(receiveOutput: {
|
||||||
guard let maxId = $0.info.maxId else { return }
|
guard let maxId = $0.info.maxId else { return }
|
||||||
|
|
|
@ -12,7 +12,7 @@ public protocol CollectionService {
|
||||||
var titleLocalizationComponents: AnyPublisher<[String], Never> { get }
|
var titleLocalizationComponents: AnyPublisher<[String], Never> { get }
|
||||||
var navigationService: NavigationService { get }
|
var navigationService: NavigationService { get }
|
||||||
var markerTimeline: Marker.Timeline? { get }
|
var markerTimeline: Marker.Timeline? { get }
|
||||||
func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error>
|
func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CollectionService {
|
extension CollectionService {
|
||||||
|
|
|
@ -24,7 +24,7 @@ public struct ContextService {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ContextService: CollectionService {
|
extension ContextService: CollectionService {
|
||||||
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> {
|
public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
|
||||||
mastodonAPIClient.request(StatusEndpoint.status(id: id))
|
mastodonAPIClient.request(StatusEndpoint.status(id: id))
|
||||||
.flatMap(contentDatabase.insert(status:))
|
.flatMap(contentDatabase.insert(status:))
|
||||||
.merge(with: mastodonAPIClient.request(ContextEndpoint.context(id: id))
|
.merge(with: mastodonAPIClient.request(ContextEndpoint.context(id: id))
|
||||||
|
|
|
@ -27,7 +27,7 @@ public struct ConversationsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConversationsService: CollectionService {
|
extension ConversationsService: CollectionService {
|
||||||
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> {
|
public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
|
||||||
mastodonAPIClient.pagedRequest(ConversationsEndpoint.conversations, maxId: maxId, minId: minId)
|
mastodonAPIClient.pagedRequest(ConversationsEndpoint.conversations, maxId: maxId, minId: minId)
|
||||||
.handleEvents(receiveOutput: {
|
.handleEvents(receiveOutput: {
|
||||||
guard let maxId = $0.info.maxId else { return }
|
guard let maxId = $0.info.maxId else { return }
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import DB
|
||||||
|
import Foundation
|
||||||
|
import Mastodon
|
||||||
|
import MastodonAPI
|
||||||
|
|
||||||
|
public struct ExploreService {
|
||||||
|
private let mastodonAPIClient: MastodonAPIClient
|
||||||
|
private let contentDatabase: ContentDatabase
|
||||||
|
|
||||||
|
init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
||||||
|
self.mastodonAPIClient = mastodonAPIClient
|
||||||
|
self.contentDatabase = contentDatabase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ExploreService {
|
||||||
|
func searchService() -> SearchService {
|
||||||
|
SearchService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||||
|
}
|
||||||
|
}
|
|
@ -249,6 +249,10 @@ public extension IdentityService {
|
||||||
contentDatabase: contentDatabase)
|
contentDatabase: contentDatabase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exploreService() -> ExploreService {
|
||||||
|
ExploreService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||||
|
}
|
||||||
|
|
||||||
func notificationsService() -> NotificationsService {
|
func notificationsService() -> NotificationsService {
|
||||||
NotificationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
NotificationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||||
}
|
}
|
||||||
|
|
|
@ -114,7 +114,7 @@ private extension NavigationService {
|
||||||
func webfinger(url: URL) -> AnyPublisher<Navigation, Never> {
|
func webfinger(url: URL) -> AnyPublisher<Navigation, Never> {
|
||||||
let navigationSubject = PassthroughSubject<Navigation, Never>()
|
let navigationSubject = PassthroughSubject<Navigation, Never>()
|
||||||
|
|
||||||
let request = mastodonAPIClient.request(ResultsEndpoint.search(query: url.absoluteString, resolve: true))
|
let request = mastodonAPIClient.request(ResultsEndpoint.search(.init(query: url.absoluteString, resolve: true)))
|
||||||
.handleEvents(
|
.handleEvents(
|
||||||
receiveSubscription: { _ in navigationSubject.send(.webfingerStart) },
|
receiveSubscription: { _ in navigationSubject.send(.webfingerStart) },
|
||||||
receiveCompletion: { _ in navigationSubject.send(.webfingerEnd) })
|
receiveCompletion: { _ in navigationSubject.send(.webfingerEnd) })
|
||||||
|
|
|
@ -39,7 +39,7 @@ public struct NotificationsService {
|
||||||
extension NotificationsService: CollectionService {
|
extension NotificationsService: CollectionService {
|
||||||
public var markerTimeline: Marker.Timeline? { .notifications }
|
public var markerTimeline: Marker.Timeline? { .notifications }
|
||||||
|
|
||||||
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> {
|
public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
|
||||||
mastodonAPIClient.pagedRequest(NotificationsEndpoint.notifications, maxId: maxId, minId: minId)
|
mastodonAPIClient.pagedRequest(NotificationsEndpoint.notifications, maxId: maxId, minId: minId)
|
||||||
.handleEvents(receiveOutput: {
|
.handleEvents(receiveOutput: {
|
||||||
guard let maxId = $0.info.maxId, maxId < nextPageMaxIdSubject.value else { return }
|
guard let maxId = $0.info.maxId, maxId < nextPageMaxIdSubject.value else { return }
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import DB
|
||||||
|
import Foundation
|
||||||
|
import Mastodon
|
||||||
|
import MastodonAPI
|
||||||
|
|
||||||
|
public struct SearchService {
|
||||||
|
public let sections: AnyPublisher<[[CollectionItem]], Error>
|
||||||
|
public let navigationService: NavigationService
|
||||||
|
public let nextPageMaxId: AnyPublisher<String, Never>
|
||||||
|
|
||||||
|
private let mastodonAPIClient: MastodonAPIClient
|
||||||
|
private let contentDatabase: ContentDatabase
|
||||||
|
private let nextPageMaxIdSubject = PassthroughSubject<String, Never>()
|
||||||
|
private let sectionsSubject = PassthroughSubject<[[CollectionItem]], Error>()
|
||||||
|
|
||||||
|
init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
||||||
|
self.mastodonAPIClient = mastodonAPIClient
|
||||||
|
self.contentDatabase = contentDatabase
|
||||||
|
nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher()
|
||||||
|
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||||
|
sections = sectionsSubject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SearchService: CollectionService {
|
||||||
|
public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
|
||||||
|
guard let search = search else { return Empty().eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
return mastodonAPIClient.request(ResultsEndpoint.search(search))
|
||||||
|
.flatMap(contentDatabase.process(results:))
|
||||||
|
.handleEvents(receiveOutput: sectionsSubject.send)
|
||||||
|
.ignoreOutput()
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
|
@ -44,7 +44,7 @@ extension TimelineService: CollectionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> {
|
public func request(maxId: String?, minId: String?, search: Search?) -> AnyPublisher<Never, Error> {
|
||||||
mastodonAPIClient.pagedRequest(timeline.endpoint, maxId: maxId, minId: minId)
|
mastodonAPIClient.pagedRequest(timeline.endpoint, maxId: maxId, minId: minId)
|
||||||
.handleEvents(receiveOutput: {
|
.handleEvents(receiveOutput: {
|
||||||
if let maxId = $0.info.maxId {
|
if let maxId = $0.info.maxId {
|
||||||
|
|
50
View Controllers/ExploreViewController.swift
Normal file
50
View Controllers/ExploreViewController.swift
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
// Copyright © 2021 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import ViewModels
|
||||||
|
|
||||||
|
final class ExploreViewController: UICollectionViewController {
|
||||||
|
private let viewModel: ExploreViewModel
|
||||||
|
private let rootViewModel: RootViewModel
|
||||||
|
private let identification: Identification
|
||||||
|
|
||||||
|
init(viewModel: ExploreViewModel, rootViewModel: RootViewModel, identification: Identification) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.rootViewModel = rootViewModel
|
||||||
|
self.identification = identification
|
||||||
|
|
||||||
|
super.init(collectionViewLayout: UICollectionViewFlowLayout())
|
||||||
|
|
||||||
|
tabBarItem = UITabBarItem(
|
||||||
|
title: NSLocalizedString("main-navigation.explore", comment: ""),
|
||||||
|
image: UIImage(systemName: "magnifyingglass"),
|
||||||
|
selectedImage: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
navigationItem.title = NSLocalizedString("main-navigation.explore", comment: "")
|
||||||
|
|
||||||
|
let searchController = UISearchController(
|
||||||
|
searchResultsController: TableViewController(
|
||||||
|
viewModel: viewModel.searchViewModel,
|
||||||
|
rootViewModel: rootViewModel,
|
||||||
|
identification: identification,
|
||||||
|
parentNavigationController: navigationController))
|
||||||
|
|
||||||
|
searchController.searchResultsUpdater = self
|
||||||
|
navigationItem.searchController = searchController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ExploreViewController: UISearchResultsUpdating {
|
||||||
|
func updateSearchResults(for searchController: UISearchController) {
|
||||||
|
viewModel.searchViewModel.query = searchController.searchBar.text ?? ""
|
||||||
|
}
|
||||||
|
}
|
|
@ -54,9 +54,15 @@ final class MainNavigationViewController: UITabBarController {
|
||||||
|
|
||||||
private extension MainNavigationViewController {
|
private extension MainNavigationViewController {
|
||||||
func setupViewControllers() {
|
func setupViewControllers() {
|
||||||
var controllers: [UIViewController] = [TimelinesViewController(
|
var controllers: [UIViewController] = [
|
||||||
viewModel: viewModel,
|
TimelinesViewController(
|
||||||
rootViewModel: rootViewModel)]
|
viewModel: viewModel,
|
||||||
|
rootViewModel: rootViewModel),
|
||||||
|
ExploreViewController(
|
||||||
|
viewModel: viewModel.exploreViewModel,
|
||||||
|
rootViewModel: rootViewModel,
|
||||||
|
identification: viewModel.identification)
|
||||||
|
]
|
||||||
|
|
||||||
if let notificationsViewModel = viewModel.notificationsViewModel {
|
if let notificationsViewModel = viewModel.notificationsViewModel {
|
||||||
let notificationsViewController = TableViewController(
|
let notificationsViewController = TableViewController(
|
||||||
|
|
|
@ -9,10 +9,18 @@ final class ProfileViewController: TableViewController {
|
||||||
private let viewModel: ProfileViewModel
|
private let viewModel: ProfileViewModel
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
required init(viewModel: ProfileViewModel, rootViewModel: RootViewModel, identification: Identification) {
|
required init(
|
||||||
|
viewModel: ProfileViewModel,
|
||||||
|
rootViewModel: RootViewModel,
|
||||||
|
identification: Identification,
|
||||||
|
parentNavigationController: UINavigationController?) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
|
|
||||||
super.init(viewModel: viewModel, rootViewModel: rootViewModel, identification: identification)
|
super.init(
|
||||||
|
viewModel: viewModel,
|
||||||
|
rootViewModel: rootViewModel,
|
||||||
|
identification: identification,
|
||||||
|
parentNavigationController: parentNavigationController)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
|
|
|
@ -21,15 +21,20 @@ class TableViewController: UITableViewController {
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
|
private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
|
||||||
private var shouldKeepPlayingVideoAfterDismissal = false
|
private var shouldKeepPlayingVideoAfterDismissal = false
|
||||||
|
private weak var parentNavigationController: UINavigationController?
|
||||||
|
|
||||||
private lazy var dataSource: TableViewDataSource = {
|
private lazy var dataSource: TableViewDataSource = {
|
||||||
.init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:))
|
.init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
init(viewModel: CollectionViewModel, rootViewModel: RootViewModel, identification: Identification) {
|
init(viewModel: CollectionViewModel,
|
||||||
|
rootViewModel: RootViewModel,
|
||||||
|
identification: Identification,
|
||||||
|
parentNavigationController: UINavigationController? = nil) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.rootViewModel = rootViewModel
|
self.rootViewModel = rootViewModel
|
||||||
self.identification = identification
|
self.identification = identification
|
||||||
|
self.parentNavigationController = parentNavigationController
|
||||||
|
|
||||||
super.init(style: .plain)
|
super.init(style: .plain)
|
||||||
}
|
}
|
||||||
|
@ -51,7 +56,7 @@ class TableViewController: UITableViewController {
|
||||||
refreshControl = UIRefreshControl()
|
refreshControl = UIRefreshControl()
|
||||||
refreshControl?.addAction(
|
refreshControl?.addAction(
|
||||||
UIAction { [weak self] _ in
|
UIAction { [weak self] _ in
|
||||||
self?.viewModel.request(maxId: nil, minId: nil) },
|
self?.viewModel.request(maxId: nil, minId: nil, search: nil) },
|
||||||
for: .valueChanged)
|
for: .valueChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,7 +74,7 @@ class TableViewController: UITableViewController {
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
viewModel.request(maxId: nil, minId: nil)
|
viewModel.request(maxId: nil, minId: nil, search: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
@ -104,7 +109,8 @@ class TableViewController: UITableViewController {
|
||||||
let maxId = viewModel.preferLastPresentIdOverNextPageMaxId
|
let maxId = viewModel.preferLastPresentIdOverNextPageMaxId
|
||||||
? dataSource.itemIdentifier(for: indexPath)?.itemId
|
? dataSource.itemIdentifier(for: indexPath)?.itemId
|
||||||
: viewModel.nextPageMaxId {
|
: viewModel.nextPageMaxId {
|
||||||
viewModel.request(maxId: maxId, minId: nil)
|
// TODO: search offset
|
||||||
|
viewModel.request(maxId: maxId, minId: nil, search: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let loadMoreView = cell.contentView as? LoadMoreView {
|
if let loadMoreView = cell.contentView as? LoadMoreView {
|
||||||
|
@ -336,21 +342,33 @@ private extension TableViewController {
|
||||||
func handle(navigation: Navigation) {
|
func handle(navigation: Navigation) {
|
||||||
switch navigation {
|
switch navigation {
|
||||||
case let .collection(collectionService):
|
case let .collection(collectionService):
|
||||||
show(TableViewController(
|
let vc = TableViewController(
|
||||||
viewModel: CollectionItemsViewModel(
|
viewModel: CollectionItemsViewModel(
|
||||||
collectionService: collectionService,
|
collectionService: collectionService,
|
||||||
identification: identification),
|
|
||||||
rootViewModel: rootViewModel,
|
|
||||||
identification: identification),
|
identification: identification),
|
||||||
sender: self)
|
rootViewModel: rootViewModel,
|
||||||
|
identification: identification,
|
||||||
|
parentNavigationController: parentNavigationController)
|
||||||
|
|
||||||
|
if let parentNavigationController = parentNavigationController {
|
||||||
|
parentNavigationController.pushViewController(vc, animated: true)
|
||||||
|
} else {
|
||||||
|
show(vc, sender: self)
|
||||||
|
}
|
||||||
case let .profile(profileService):
|
case let .profile(profileService):
|
||||||
show(ProfileViewController(
|
let vc = ProfileViewController(
|
||||||
viewModel: ProfileViewModel(
|
viewModel: ProfileViewModel(
|
||||||
profileService: profileService,
|
profileService: profileService,
|
||||||
identification: identification),
|
|
||||||
rootViewModel: rootViewModel,
|
|
||||||
identification: identification),
|
identification: identification),
|
||||||
sender: self)
|
rootViewModel: rootViewModel,
|
||||||
|
identification: identification,
|
||||||
|
parentNavigationController: parentNavigationController)
|
||||||
|
|
||||||
|
if let parentNavigationController = parentNavigationController {
|
||||||
|
parentNavigationController.pushViewController(vc, animated: true)
|
||||||
|
} else {
|
||||||
|
show(vc, sender: self)
|
||||||
|
}
|
||||||
case let .url(url):
|
case let .url(url):
|
||||||
present(SFSafariViewController(url: url), animated: true)
|
present(SFSafariViewController(url: url), animated: true)
|
||||||
case .webfingerStart:
|
case .webfingerStart:
|
||||||
|
|
|
@ -35,6 +35,11 @@ final class TimelinesViewController: UIPageViewController {
|
||||||
if let firstViewController = timelineViewControllers.first {
|
if let firstViewController = timelineViewControllers.first {
|
||||||
setViewControllers([firstViewController], direction: .forward, animated: false)
|
setViewControllers([firstViewController], direction: .forward, animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tabBarItem = UITabBarItem(
|
||||||
|
title: NSLocalizedString("main-navigation.timelines", comment: ""),
|
||||||
|
image: UIImage(systemName: "newspaper"),
|
||||||
|
selectedImage: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
|
@ -48,11 +53,6 @@ final class TimelinesViewController: UIPageViewController {
|
||||||
dataSource = self
|
dataSource = self
|
||||||
delegate = self
|
delegate = self
|
||||||
|
|
||||||
tabBarItem = UITabBarItem(
|
|
||||||
title: NSLocalizedString("main-navigation.timelines", comment: ""),
|
|
||||||
image: UIImage(systemName: "newspaper"),
|
|
||||||
selectedImage: nil)
|
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "megaphone"), primaryAction: nil)
|
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "megaphone"), primaryAction: nil)
|
||||||
navigationItem.titleView = segmentedControl
|
navigationItem.titleView = segmentedControl
|
||||||
segmentedControl.selectedSegmentIndex = 0
|
segmentedControl.selectedSegmentIndex = 0
|
||||||
|
|
|
@ -5,7 +5,7 @@ import Foundation
|
||||||
import Mastodon
|
import Mastodon
|
||||||
import ServiceLayer
|
import ServiceLayer
|
||||||
|
|
||||||
public final class CollectionItemsViewModel: ObservableObject {
|
public class CollectionItemsViewModel: ObservableObject {
|
||||||
@Published public var alertItem: AlertItem?
|
@Published public var alertItem: AlertItem?
|
||||||
public private(set) var nextPageMaxId: String?
|
public private(set) var nextPageMaxId: String?
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ extension CollectionItemsViewModel: CollectionViewModel {
|
||||||
|
|
||||||
public var canRefresh: Bool { collectionService.canRefresh }
|
public var canRefresh: Bool { collectionService.canRefresh }
|
||||||
|
|
||||||
public func request(maxId: String? = nil, minId: String? = nil) {
|
public func request(maxId: String? = nil, minId: String? = nil, search: Search?) {
|
||||||
let publisher: AnyPublisher<Never, Error>
|
let publisher: AnyPublisher<Never, Error>
|
||||||
|
|
||||||
if let markerTimeline = collectionService.markerTimeline,
|
if let markerTimeline = collectionService.markerTimeline,
|
||||||
|
@ -90,19 +90,22 @@ extension CollectionItemsViewModel: CollectionViewModel {
|
||||||
!hasRequestedUsingMarker {
|
!hasRequestedUsingMarker {
|
||||||
publisher = identification.service.getMarker(markerTimeline)
|
publisher = identification.service.getMarker(markerTimeline)
|
||||||
.flatMap { [weak self] in
|
.flatMap { [weak self] in
|
||||||
self?.collectionService.request(maxId: $0.lastReadId, minId: nil) ?? Empty().eraseToAnyPublisher()
|
self?.collectionService.request(maxId: $0.lastReadId, minId: nil, search: nil)
|
||||||
|
?? Empty().eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
.catch { [weak self] _ in
|
.catch { [weak self] _ in
|
||||||
self?.collectionService.request(maxId: nil, minId: nil) ?? Empty().eraseToAnyPublisher()
|
self?.collectionService.request(maxId: nil, minId: nil, search: nil)
|
||||||
|
?? Empty().eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
.collect()
|
.collect()
|
||||||
.flatMap { [weak self] _ in
|
.flatMap { [weak self] _ in
|
||||||
self?.collectionService.request(maxId: nil, minId: nil) ?? Empty().eraseToAnyPublisher()
|
self?.collectionService.request(maxId: nil, minId: nil, search: nil)
|
||||||
|
?? Empty().eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
self.hasRequestedUsingMarker = true
|
self.hasRequestedUsingMarker = true
|
||||||
} else {
|
} else {
|
||||||
publisher = collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId)
|
publisher = collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId, search: search)
|
||||||
}
|
}
|
||||||
|
|
||||||
publisher
|
publisher
|
||||||
|
|
|
@ -14,7 +14,7 @@ public protocol CollectionViewModel {
|
||||||
var nextPageMaxId: String? { get }
|
var nextPageMaxId: String? { get }
|
||||||
var preferLastPresentIdOverNextPageMaxId: Bool { get }
|
var preferLastPresentIdOverNextPageMaxId: Bool { get }
|
||||||
var canRefresh: Bool { get }
|
var canRefresh: Bool { get }
|
||||||
func request(maxId: String?, minId: String?)
|
func request(maxId: String?, minId: String?, search: Search?)
|
||||||
func viewedAtTop(indexPath: IndexPath)
|
func viewedAtTop(indexPath: IndexPath)
|
||||||
func select(indexPath: IndexPath)
|
func select(indexPath: IndexPath)
|
||||||
func canSelect(indexPath: IndexPath) -> Bool
|
func canSelect(indexPath: IndexPath) -> Bool
|
||||||
|
|
5
ViewModels/Sources/ViewModels/Entities/Search.swift
Normal file
5
ViewModels/Sources/ViewModels/Entities/Search.swift
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import ServiceLayer
|
||||||
|
|
||||||
|
public typealias Search = ServiceLayer.Search
|
19
ViewModels/Sources/ViewModels/ExploreViewModel.swift
Normal file
19
ViewModels/Sources/ViewModels/ExploreViewModel.swift
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ServiceLayer
|
||||||
|
|
||||||
|
public final class ExploreViewModel: ObservableObject {
|
||||||
|
public let searchViewModel: SearchViewModel
|
||||||
|
|
||||||
|
private let exploreService: ExploreService
|
||||||
|
private let identification: Identification
|
||||||
|
|
||||||
|
init(service: ExploreService, identification: Identification) {
|
||||||
|
exploreService = service
|
||||||
|
self.identification = identification
|
||||||
|
searchViewModel = SearchViewModel(
|
||||||
|
searchService: exploreService.searchService(),
|
||||||
|
identification: identification)
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,13 +13,23 @@ public final class NavigationViewModel: ObservableObject {
|
||||||
@Published public var presentingSecondaryNavigation = false
|
@Published public var presentingSecondaryNavigation = false
|
||||||
@Published public var alertItem: AlertItem?
|
@Published public var alertItem: AlertItem?
|
||||||
|
|
||||||
|
public lazy var exploreViewModel: ExploreViewModel = {
|
||||||
|
let exploreViewModel = ExploreViewModel(
|
||||||
|
service: identification.service.exploreService(),
|
||||||
|
identification: identification)
|
||||||
|
|
||||||
|
// TODO: initial request
|
||||||
|
|
||||||
|
return exploreViewModel
|
||||||
|
}()
|
||||||
|
|
||||||
public lazy var notificationsViewModel: CollectionViewModel? = {
|
public lazy var notificationsViewModel: CollectionViewModel? = {
|
||||||
if identification.identity.authenticated {
|
if identification.identity.authenticated {
|
||||||
let notificationsViewModel = CollectionItemsViewModel(
|
let notificationsViewModel = CollectionItemsViewModel(
|
||||||
collectionService: identification.service.notificationsService(),
|
collectionService: identification.service.notificationsService(),
|
||||||
identification: identification)
|
identification: identification)
|
||||||
|
|
||||||
notificationsViewModel.request(maxId: nil, minId: nil)
|
notificationsViewModel.request(maxId: nil, minId: nil, search: nil)
|
||||||
|
|
||||||
return notificationsViewModel
|
return notificationsViewModel
|
||||||
} else {
|
} else {
|
||||||
|
@ -33,7 +43,7 @@ public final class NavigationViewModel: ObservableObject {
|
||||||
collectionService: identification.service.conversationsService(),
|
collectionService: identification.service.conversationsService(),
|
||||||
identification: identification)
|
identification: identification)
|
||||||
|
|
||||||
conversationsViewModel.request(maxId: nil, minId: nil)
|
conversationsViewModel.request(maxId: nil, minId: nil, search: nil)
|
||||||
|
|
||||||
return conversationsViewModel
|
return conversationsViewModel
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -105,7 +105,7 @@ extension ProfileViewModel: CollectionViewModel {
|
||||||
|
|
||||||
public var canRefresh: Bool { collectionViewModel.value.canRefresh }
|
public var canRefresh: Bool { collectionViewModel.value.canRefresh }
|
||||||
|
|
||||||
public func request(maxId: String?, minId: String?) {
|
public func request(maxId: String?, minId: String?, search: Search?) {
|
||||||
if case .statuses = collection, maxId == nil {
|
if case .statuses = collection, maxId == nil {
|
||||||
profileService.fetchPinnedStatuses()
|
profileService.fetchPinnedStatuses()
|
||||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||||
|
@ -113,7 +113,7 @@ extension ProfileViewModel: CollectionViewModel {
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
collectionViewModel.value.request(maxId: maxId, minId: minId)
|
collectionViewModel.value.request(maxId: maxId, minId: minId, search: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func viewedAtTop(indexPath: IndexPath) {
|
public func viewedAtTop(indexPath: IndexPath) {
|
||||||
|
|
27
ViewModels/Sources/ViewModels/SearchViewModel.swift
Normal file
27
ViewModels/Sources/ViewModels/SearchViewModel.swift
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import ServiceLayer
|
||||||
|
|
||||||
|
public final class SearchViewModel: CollectionItemsViewModel {
|
||||||
|
@Published public var query = ""
|
||||||
|
|
||||||
|
private let searchService: SearchService
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
public init(searchService: SearchService, identification: Identification) {
|
||||||
|
self.searchService = searchService
|
||||||
|
|
||||||
|
super.init(collectionService: searchService, identification: identification)
|
||||||
|
|
||||||
|
$query.throttle(for: .seconds(Self.queryThrottleInterval), scheduler: DispatchQueue.global(), latest: true)
|
||||||
|
.sink { [weak self] in self?.request(maxId: nil, minId: nil, search: .init(query: $0, limit: Self.limit)) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension SearchViewModel {
|
||||||
|
static let queryThrottleInterval: TimeInterval = 0.5
|
||||||
|
static let limit = 5
|
||||||
|
}
|
|
@ -310,7 +310,7 @@ private extension AccountHeaderView {
|
||||||
segmentedControl.insertSegment(
|
segmentedControl.insertSegment(
|
||||||
action: UIAction(title: collection.title) { [weak self] _ in
|
action: UIAction(title: collection.title) { [weak self] _ in
|
||||||
self?.viewModel?.collection = collection
|
self?.viewModel?.collection = collection
|
||||||
self?.viewModel?.request(maxId: nil, minId: nil)
|
self?.viewModel?.request(maxId: nil, minId: nil, search: nil)
|
||||||
},
|
},
|
||||||
at: index,
|
at: index,
|
||||||
animated: false)
|
animated: false)
|
||||||
|
|
Loading…
Reference in a new issue