mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-22 08:10:59 +00:00
URL navigation
This commit is contained in:
parent
a595133d70
commit
d7c99a08a8
8 changed files with 171 additions and 56 deletions
|
@ -1,42 +0,0 @@
|
||||||
// Copyright © 2020 Metabolist. All rights reserved.
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension URL {
|
|
||||||
var isAccountURL: Bool {
|
|
||||||
(pathComponents.count == 2 && pathComponents[1].starts(with: "@"))
|
|
||||||
|| (pathComponents.count == 3 && pathComponents[0...1] == ["/", "users"])
|
|
||||||
}
|
|
||||||
|
|
||||||
var accountID: String? {
|
|
||||||
if let accountID = pathComponents.last, pathComponents == ["/", "web", "accounts", accountID] {
|
|
||||||
return accountID
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusID: String? {
|
|
||||||
guard let statusID = pathComponents.last else { return nil }
|
|
||||||
|
|
||||||
if pathComponents.count == 3, pathComponents[1].starts(with: "@") {
|
|
||||||
return statusID
|
|
||||||
} else if pathComponents == ["/", "web", "statuses", statusID] {
|
|
||||||
return statusID
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var tag: String? {
|
|
||||||
if let tag = pathComponents.last, pathComponents == ["/", "tags", tag] {
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var shouldWebfinger: Bool {
|
|
||||||
isAccountURL || accountID != nil || statusID != nil || tag != nil
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -51,7 +51,7 @@ public extension StatusListService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusService(status: Status) -> StatusService {
|
func statusService(status: Status) -> StatusService {
|
||||||
StatusService(status: status, networkClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
StatusService(status: status, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextService(statusID: String) -> Self {
|
func contextService(statusID: String) -> Self {
|
||||||
|
|
|
@ -8,12 +8,17 @@ import MastodonAPI
|
||||||
|
|
||||||
public struct StatusService {
|
public struct StatusService {
|
||||||
public let status: Status
|
public let status: Status
|
||||||
|
public let urlService: URLService
|
||||||
private let mastodonAPIClient: MastodonAPIClient
|
private let mastodonAPIClient: MastodonAPIClient
|
||||||
private let contentDatabase: ContentDatabase
|
private let contentDatabase: ContentDatabase
|
||||||
|
|
||||||
init(status: Status, networkClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
init(status: Status, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
||||||
self.status = status
|
self.status = status
|
||||||
self.mastodonAPIClient = networkClient
|
self.urlService = URLService(
|
||||||
|
status: status.displayStatus,
|
||||||
|
mastodonAPIClient: mastodonAPIClient,
|
||||||
|
contentDatabase: contentDatabase)
|
||||||
|
self.mastodonAPIClient = mastodonAPIClient
|
||||||
self.contentDatabase = contentDatabase
|
self.contentDatabase = contentDatabase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
103
ServiceLayer/Sources/ServiceLayer/Services/URLService.swift
Normal file
103
ServiceLayer/Sources/ServiceLayer/Services/URLService.swift
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import DB
|
||||||
|
import Foundation
|
||||||
|
import Mastodon
|
||||||
|
import MastodonAPI
|
||||||
|
|
||||||
|
public enum URLItem {
|
||||||
|
case url(URL)
|
||||||
|
case statusID(String)
|
||||||
|
case accountID(String)
|
||||||
|
case tag(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct URLService {
|
||||||
|
private let status: Status?
|
||||||
|
private let mastodonAPIClient: MastodonAPIClient
|
||||||
|
private let contentDatabase: ContentDatabase
|
||||||
|
|
||||||
|
init(status: Status?, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
||||||
|
self.status = status
|
||||||
|
self.mastodonAPIClient = mastodonAPIClient
|
||||||
|
self.contentDatabase = contentDatabase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension URLService {
|
||||||
|
func item(url: URL) -> AnyPublisher<URLItem, Never> {
|
||||||
|
if let tag = tag(url: url) {
|
||||||
|
return Just(.tag(tag)).eraseToAnyPublisher()
|
||||||
|
} else if let accountID = accountID(url: url) {
|
||||||
|
return Just(.accountID(accountID)).eraseToAnyPublisher()
|
||||||
|
} else if mastodonAPIClient.instanceURL.host == url.host, let statusID = url.statusID {
|
||||||
|
return Just(.statusID(statusID)).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Just(.url(url)).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension URLService {
|
||||||
|
func tag(url: URL) -> String? {
|
||||||
|
if status?.tags.first(where: { $0.url.path.lowercased() == url.path.lowercased() }) != nil {
|
||||||
|
return url.lastPathComponent
|
||||||
|
} else if
|
||||||
|
mastodonAPIClient.instanceURL.host == url.host {
|
||||||
|
return url.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountID(url: URL) -> String? {
|
||||||
|
if let mentionID = status?.mentions.first(where: { $0.url.path.lowercased() == url.path.lowercased() })?.id {
|
||||||
|
return mentionID
|
||||||
|
} else if
|
||||||
|
mastodonAPIClient.instanceURL.host == url.host {
|
||||||
|
return url.accountID
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension URL {
|
||||||
|
var isAccountURL: Bool {
|
||||||
|
(pathComponents.count == 2 && pathComponents[1].starts(with: "@"))
|
||||||
|
|| (pathComponents.count == 3 && pathComponents[0...1] == ["/", "users"])
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountID: String? {
|
||||||
|
if let accountID = pathComponents.last, pathComponents == ["/", "web", "accounts", accountID] {
|
||||||
|
return accountID
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusID: String? {
|
||||||
|
guard let statusID = pathComponents.last else { return nil }
|
||||||
|
|
||||||
|
if pathComponents.count == 3, pathComponents[1].starts(with: "@") {
|
||||||
|
return statusID
|
||||||
|
} else if pathComponents == ["/", "web", "statuses", statusID] {
|
||||||
|
return statusID
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tag: String? {
|
||||||
|
if let tag = pathComponents.last, pathComponents == ["/", "tags", tag] {
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldWebfinger: Bool {
|
||||||
|
isAccountURL || accountID != nil || statusID != nil || tag != nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2020 Metabolist. All rights reserved.
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import SafariServices
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ViewModels
|
import ViewModels
|
||||||
|
|
||||||
|
@ -78,11 +79,15 @@ final class StatusListViewController: UITableViewController {
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
viewModel.statusEvents.sink { [weak self] in
|
viewModel.events.sink { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
switch $0 {
|
switch $0 {
|
||||||
case .ignorableOutput, .statusListNavigation, .urlNavigation: break
|
|
||||||
case let .share(url):
|
case let .share(url):
|
||||||
self?.share(url: url)
|
self.share(url: url)
|
||||||
|
case let .statusListNavigation(statusListViewModel):
|
||||||
|
self.show(StatusListViewController(viewModel: statusListViewModel), sender: self)
|
||||||
|
case let .urlNavigation(url):
|
||||||
|
self.present(SFSafariViewController(url: url), animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
|
@ -9,18 +9,18 @@ public final class StatusListViewModel: ObservableObject {
|
||||||
@Published public private(set) var statusIDs = [[String]]()
|
@Published public private(set) var statusIDs = [[String]]()
|
||||||
@Published public var alertItem: AlertItem?
|
@Published public var alertItem: AlertItem?
|
||||||
@Published public private(set) var loading = false
|
@Published public private(set) var loading = false
|
||||||
public let statusEvents: AnyPublisher<StatusViewModel.Event, Never>
|
public let events: AnyPublisher<Event, Never>
|
||||||
public private(set) var maintainScrollPositionOfStatusID: String?
|
public private(set) var maintainScrollPositionOfStatusID: String?
|
||||||
|
|
||||||
private var statuses = [String: Status]()
|
private var statuses = [String: Status]()
|
||||||
private let statusListService: StatusListService
|
private let statusListService: StatusListService
|
||||||
private var statusViewModelCache = [Status: (StatusViewModel, AnyCancellable)]()
|
private var statusViewModelCache = [Status: (StatusViewModel, AnyCancellable)]()
|
||||||
private let statusEventsSubject = PassthroughSubject<StatusViewModel.Event, Never>()
|
private let eventsSubject = PassthroughSubject<Event, Never>()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(statusListService: StatusListService) {
|
init(statusListService: StatusListService) {
|
||||||
self.statusListService = statusListService
|
self.statusListService = statusListService
|
||||||
statusEvents = statusEventsSubject.eraseToAnyPublisher()
|
events = eventsSubject.eraseToAnyPublisher()
|
||||||
|
|
||||||
statusListService.statusSections
|
statusListService.statusSections
|
||||||
.combineLatest(statusListService.filters.map { $0.regularExpression() })
|
.combineLatest(statusListService.filters.map { $0.regularExpression() })
|
||||||
|
@ -37,6 +37,14 @@ public final class StatusListViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension StatusListViewModel {
|
||||||
|
enum Event {
|
||||||
|
case statusListNavigation(StatusListViewModel)
|
||||||
|
case urlNavigation(URL)
|
||||||
|
case share(URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public extension StatusListViewModel {
|
public extension StatusListViewModel {
|
||||||
var paginates: Bool { statusListService.paginates }
|
var paginates: Bool { statusListService.paginates }
|
||||||
|
|
||||||
|
@ -66,7 +74,12 @@ public extension StatusListViewModel {
|
||||||
statusViewModel.events
|
statusViewModel.events
|
||||||
.flatMap { $0 }
|
.flatMap { $0 }
|
||||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||||
.sink { [weak self] in self?.statusEventsSubject.send($0) })
|
.sink { [weak self] in
|
||||||
|
guard let self = self,
|
||||||
|
let event = self.event(statusEvent: $0)
|
||||||
|
else { return }
|
||||||
|
self.eventsSubject.send(event)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
statusViewModel.isContextParent = status.id == statusListService.contextParentID
|
statusViewModel.isContextParent = status.id == statusListService.contextParentID
|
||||||
|
@ -93,6 +106,28 @@ private extension StatusListViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func event(statusEvent: StatusViewModel.Event) -> Event? {
|
||||||
|
switch statusEvent {
|
||||||
|
case .ignorableOutput:
|
||||||
|
return nil
|
||||||
|
case let .navigation(item):
|
||||||
|
switch item {
|
||||||
|
case let .url(url):
|
||||||
|
return .urlNavigation(url)
|
||||||
|
case let .accountID(id):
|
||||||
|
return nil
|
||||||
|
case let .statusID(id):
|
||||||
|
return .statusListNavigation(
|
||||||
|
StatusListViewModel(
|
||||||
|
statusListService: statusListService.contextService(statusID: id)))
|
||||||
|
case let .tag(tag):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case let .share(url):
|
||||||
|
return .share(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func determineIfScrollPositionShouldBeMaintained(newStatusSections: [[Status]]) {
|
func determineIfScrollPositionShouldBeMaintained(newStatusSections: [[Status]]) {
|
||||||
maintainScrollPositionOfStatusID = nil // clear old value
|
maintainScrollPositionOfStatusID = nil // clear old value
|
||||||
|
|
||||||
|
|
|
@ -52,8 +52,7 @@ public struct StatusViewModel {
|
||||||
public extension StatusViewModel {
|
public extension StatusViewModel {
|
||||||
enum Event {
|
enum Event {
|
||||||
case ignorableOutput
|
case ignorableOutput
|
||||||
case statusListNavigation(StatusListViewModel)
|
case navigation(URLItem)
|
||||||
case urlNavigation(URL)
|
|
||||||
case share(URL)
|
case share(URL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -116,6 +115,14 @@ public extension StatusViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func urlSelected(_ url: URL) {
|
||||||
|
eventsSubject.send(
|
||||||
|
statusService.urlService.item(url: url)
|
||||||
|
.map { Event.navigation($0) }
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.eraseToAnyPublisher())
|
||||||
|
}
|
||||||
|
|
||||||
func toggleFavorited() {
|
func toggleFavorited() {
|
||||||
eventsSubject.send(statusService.toggleFavorited().map { _ in Event.ignorableOutput }.eraseToAnyPublisher())
|
eventsSubject.send(statusService.toggleFavorited().map { _ in Event.ignorableOutput }.eraseToAnyPublisher())
|
||||||
}
|
}
|
||||||
|
|
|
@ -99,7 +99,9 @@ extension StatusView: UITextViewDelegate {
|
||||||
in characterRange: NSRange,
|
in characterRange: NSRange,
|
||||||
interaction: UITextItemInteraction) -> Bool {
|
interaction: UITextItemInteraction) -> Bool {
|
||||||
switch interaction {
|
switch interaction {
|
||||||
case .invokeDefaultAction: print(URL); return false
|
case .invokeDefaultAction:
|
||||||
|
statusConfiguration.viewModel.urlSelected(URL)
|
||||||
|
return false
|
||||||
case .preview: return false
|
case .preview: return false
|
||||||
case .presentActions: return false
|
case .presentActions: return false
|
||||||
@unknown default: return false
|
@unknown default: return false
|
||||||
|
|
Loading…
Reference in a new issue