URL navigation

This commit is contained in:
Justin Mazzocchi 2020-09-14 18:39:35 -07:00
parent a595133d70
commit d7c99a08a8
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
8 changed files with 171 additions and 56 deletions

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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
} }
} }

View 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
}
}

View file

@ -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)

View file

@ -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

View file

@ -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())
} }

View file

@ -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