mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-28 19:11:30 +00:00
Refactoring
This commit is contained in:
parent
7161c21807
commit
2dd1f3ebdd
16 changed files with 121 additions and 113 deletions
|
@ -9,8 +9,8 @@ struct AccountResult: Codable, Hashable, FetchableRecord {
|
|||
}
|
||||
|
||||
extension QueryInterfaceRequest where RowDecoder == AccountRecord {
|
||||
var accountResultRequest: AnyFetchRequest<AccountResult> {
|
||||
AnyFetchRequest(including(optional: AccountRecord.moved))
|
||||
var accountResultRequest: QueryInterfaceRequest<AccountResult> {
|
||||
including(optional: AccountRecord.moved)
|
||||
.asRequest(of: AccountResult.self)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,11 +74,11 @@ extension StatusRecord {
|
|||
through: descendantJoins,
|
||||
using: StatusContextJoin.status)
|
||||
|
||||
var ancestors: AnyFetchRequest<StatusResult> {
|
||||
var ancestors: QueryInterfaceRequest<StatusResult> {
|
||||
request(for: Self.ancestors).statusResultRequest
|
||||
}
|
||||
|
||||
var descendants: AnyFetchRequest<StatusResult> {
|
||||
var descendants: QueryInterfaceRequest<StatusResult> {
|
||||
request(for: Self.descendants).statusResultRequest
|
||||
}
|
||||
|
||||
|
|
|
@ -25,12 +25,12 @@ extension StatusResult {
|
|||
}
|
||||
|
||||
extension QueryInterfaceRequest where RowDecoder == StatusRecord {
|
||||
var statusResultRequest: AnyFetchRequest<StatusResult> {
|
||||
AnyFetchRequest(including(required: StatusRecord.account)
|
||||
.including(optional: StatusRecord.accountMoved)
|
||||
.including(optional: StatusRecord.reblogAccount)
|
||||
.including(optional: StatusRecord.reblogAccountMoved)
|
||||
.including(optional: StatusRecord.reblog))
|
||||
var statusResultRequest: QueryInterfaceRequest<StatusResult> {
|
||||
including(required: StatusRecord.account)
|
||||
.including(optional: StatusRecord.accountMoved)
|
||||
.including(optional: StatusRecord.reblogAccount)
|
||||
.including(optional: StatusRecord.reblogAccountMoved)
|
||||
.including(optional: StatusRecord.reblog)
|
||||
.asRequest(of: StatusResult.self)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ extension Identity {
|
|||
instance: result.instance,
|
||||
account: result.account,
|
||||
lastRegisteredDeviceToken: result.identity.lastRegisteredDeviceToken,
|
||||
pushSubscriptionAlerts: result.pushSubscriptionAlerts)
|
||||
pushSubscriptionAlerts: result.identity.pushSubscriptionAlerts)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ extension Timeline {
|
|||
using: TimelineStatusJoin.status)
|
||||
.order(Column("createdAt").desc)
|
||||
|
||||
var statuses: AnyFetchRequest<StatusResult> {
|
||||
var statuses: QueryInterfaceRequest<StatusResult> {
|
||||
request(for: Self.statuses).statusResultRequest
|
||||
}
|
||||
}
|
||||
|
|
|
@ -117,8 +117,8 @@ public extension IdentityDatabase {
|
|||
func updatePreferences(_ preferences: Identity.Preferences,
|
||||
forIdentityID identityID: UUID) -> AnyPublisher<Never, Error> {
|
||||
databaseQueue.writePublisher(updates: Self.writePreferences(preferences, id: identityID))
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func updatePushSubscription(alerts: PushSubscription.Alerts,
|
||||
|
@ -141,16 +141,14 @@ public extension IdentityDatabase {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func identityObservation(id: UUID) -> AnyPublisher<Identity, Error> {
|
||||
func identityObservation(id: UUID, immediate: Bool) -> AnyPublisher<Identity, Error> {
|
||||
ValueObservation.tracking(
|
||||
IdentityRecord
|
||||
.filter(Column("id") == id)
|
||||
.including(optional: IdentityRecord.instance)
|
||||
.including(optional: IdentityRecord.account)
|
||||
.asRequest(of: IdentityResult.self)
|
||||
.identityResultRequest
|
||||
.fetchOne)
|
||||
.removeDuplicates()
|
||||
.publisher(in: databaseQueue, scheduling: .immediate)
|
||||
.publisher(in: databaseQueue, scheduling: immediate ? .immediate : .async(onQueue: .main))
|
||||
.tryMap {
|
||||
guard let result = $0 else { throw IdentityDatabaseError.identityNotFound }
|
||||
|
||||
|
@ -160,7 +158,11 @@ public extension IdentityDatabase {
|
|||
}
|
||||
|
||||
func identitiesObservation() -> AnyPublisher<[Identity], Error> {
|
||||
ValueObservation.tracking(Self.identitiesRequest().fetchAll)
|
||||
ValueObservation.tracking(
|
||||
IdentityRecord
|
||||
.order(Column("lastUsedAt").desc)
|
||||
.identityResultRequest
|
||||
.fetchAll)
|
||||
.removeDuplicates()
|
||||
.publisher(in: databaseQueue)
|
||||
.map { $0.map(Identity.init(result:)) }
|
||||
|
@ -169,7 +171,9 @@ public extension IdentityDatabase {
|
|||
|
||||
func recentIdentitiesObservation(excluding: UUID) -> AnyPublisher<[Identity], Error> {
|
||||
ValueObservation.tracking(
|
||||
Self.identitiesRequest()
|
||||
IdentityRecord
|
||||
.order(Column("lastUsedAt").desc)
|
||||
.identityResultRequest
|
||||
.filter(Column("id") != excluding)
|
||||
.limit(9)
|
||||
.fetchAll)
|
||||
|
@ -179,7 +183,7 @@ public extension IdentityDatabase {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func mostRecentlyUsedIdentityIDObservation() -> AnyPublisher<UUID?, Error> {
|
||||
func immediateMostRecentlyUsedIdentityIDObservation() -> AnyPublisher<UUID?, Error> {
|
||||
ValueObservation.tracking(IdentityRecord.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne)
|
||||
.removeDuplicates()
|
||||
.publisher(in: databaseQueue, scheduling: .immediate)
|
||||
|
@ -188,7 +192,9 @@ public extension IdentityDatabase {
|
|||
|
||||
func identitiesWithOutdatedDeviceTokens(deviceToken: Data) -> AnyPublisher<[Identity], Error> {
|
||||
databaseQueue.readPublisher(
|
||||
value: Self.identitiesRequest()
|
||||
value: IdentityRecord
|
||||
.order(Column("lastUsedAt").desc)
|
||||
.identityResultRequest
|
||||
.filter(Column("lastRegisteredDeviceToken") != deviceToken)
|
||||
.fetchAll)
|
||||
.map { $0.map(Identity.init(result:)) }
|
||||
|
@ -199,14 +205,6 @@ public extension IdentityDatabase {
|
|||
private extension IdentityDatabase {
|
||||
private static let name = "Identity"
|
||||
|
||||
private static func identitiesRequest() -> QueryInterfaceRequest<IdentityResult> {
|
||||
IdentityRecord
|
||||
.order(Column("lastUsedAt").desc)
|
||||
.including(optional: IdentityRecord.instance)
|
||||
.including(optional: IdentityRecord.account)
|
||||
.asRequest(of: IdentityResult.self)
|
||||
}
|
||||
|
||||
private static func writePreferences(_ preferences: Identity.Preferences, id: UUID) -> (Database) throws -> Void {
|
||||
{
|
||||
let data = try IdentityRecord.databaseJSONEncoder(for: "preferences").encode(preferences)
|
||||
|
|
|
@ -8,5 +8,12 @@ struct IdentityResult: Codable, Hashable, FetchableRecord {
|
|||
let identity: IdentityRecord
|
||||
let instance: Identity.Instance?
|
||||
let account: Identity.Account?
|
||||
let pushSubscriptionAlerts: PushSubscription.Alerts
|
||||
}
|
||||
|
||||
extension QueryInterfaceRequest where RowDecoder == IdentityRecord {
|
||||
var identityResultRequest: QueryInterfaceRequest<IdentityResult> {
|
||||
including(optional: IdentityRecord.instance)
|
||||
.including(optional: IdentityRecord.account)
|
||||
.asRequest(of: IdentityResult.self)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,8 +8,6 @@ import MastodonAPI
|
|||
import Secrets
|
||||
|
||||
public struct AllIdentitiesService {
|
||||
public let mostRecentlyUsedIdentityID: AnyPublisher<UUID?, Never>
|
||||
|
||||
private let environment: AppEnvironment
|
||||
private let database: IdentityDatabase
|
||||
|
||||
|
@ -18,10 +16,6 @@ public struct AllIdentitiesService {
|
|||
self.database = try environment.fixtureDatabase ?? IdentityDatabase(
|
||||
inMemory: environment.inMemoryContent,
|
||||
keychain: environment.keychain)
|
||||
|
||||
mostRecentlyUsedIdentityID = database.mostRecentlyUsedIdentityIDObservation()
|
||||
.replaceError(with: nil)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,6 +24,10 @@ public extension AllIdentitiesService {
|
|||
try IdentityService(id: id, database: database, environment: environment)
|
||||
}
|
||||
|
||||
func immediateMostRecentlyUsedIdentityIDObservation() -> AnyPublisher<UUID?, Error> {
|
||||
database.immediateMostRecentlyUsedIdentityIDObservation()
|
||||
}
|
||||
|
||||
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
|
||||
let secrets = Secrets(identityID: id, keychain: environment.keychain)
|
||||
|
||||
|
|
|
@ -15,7 +15,6 @@ public struct IdentityService {
|
|||
private let environment: AppEnvironment
|
||||
private let mastodonAPIClient: MastodonAPIClient
|
||||
private let secrets: Secrets
|
||||
private let observationErrorsInput = PassthroughSubject<Error, Never>()
|
||||
|
||||
init(id: UUID, database: IdentityDatabase, environment: AppEnvironment) throws {
|
||||
identityID = id
|
||||
|
@ -86,8 +85,8 @@ public extension IdentityService {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func observation() -> AnyPublisher<Identity, Error> {
|
||||
identityDatabase.identityObservation(id: identityID)
|
||||
func observation(immediate: Bool) -> AnyPublisher<Identity, Error> {
|
||||
identityDatabase.identityObservation(id: identityID, immediate: immediate)
|
||||
}
|
||||
|
||||
func listsObservation() -> AnyPublisher<[Timeline], Error> {
|
||||
|
|
|
@ -4,38 +4,16 @@ import Combine
|
|||
import Foundation
|
||||
import ServiceLayer
|
||||
|
||||
enum IdentificationError: Error {
|
||||
case initialIdentityValueAbsent
|
||||
}
|
||||
|
||||
public final class Identification: ObservableObject {
|
||||
@Published private(set) var identity: Identity
|
||||
let service: IdentityService
|
||||
let observationErrors: AnyPublisher<Error, Never>
|
||||
|
||||
init(service: IdentityService) throws {
|
||||
self.service = service
|
||||
// The scheduling on the observation is immediate so an initial value can be extracted
|
||||
let sharedObservation = service.observation().share()
|
||||
var initialIdentity: Identity?
|
||||
|
||||
_ = sharedObservation.first().sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { initialIdentity = $0 })
|
||||
|
||||
guard let identity = initialIdentity else { throw IdentificationError.initialIdentityValueAbsent }
|
||||
|
||||
init(identity: Identity, observation: AnyPublisher<Identity, Never>, service: IdentityService) {
|
||||
self.identity = identity
|
||||
self.service = service
|
||||
|
||||
let observationErrorsSubject = PassthroughSubject<Error, Never>()
|
||||
|
||||
observationErrors = observationErrorsSubject.eraseToAnyPublisher()
|
||||
|
||||
sharedObservation.catch { error -> Empty<Identity, Never> in
|
||||
observationErrorsSubject.send(error)
|
||||
|
||||
return Empty()
|
||||
DispatchQueue.main.async {
|
||||
observation.dropFirst().assign(to: &self.$identity)
|
||||
}
|
||||
.assign(to: &$identity)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,24 @@ import Foundation
|
|||
import ServiceLayer
|
||||
|
||||
public final class RootViewModel: ObservableObject {
|
||||
@Published public private(set) var identification: Identification?
|
||||
@Published public private(set) var identification: Identification? {
|
||||
didSet {
|
||||
guard let identification = identification else { return }
|
||||
|
||||
identification.service.updateLastUse()
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
.store(in: &cancellables)
|
||||
|
||||
userNotificationService.isAuthorized()
|
||||
.filter { $0 }
|
||||
.zip(registerForRemoteNotifications())
|
||||
.filter { identification.identity.lastRegisteredDeviceToken != $1 }
|
||||
.map { ($1, identification.identity.pushSubscriptionAlerts) }
|
||||
.flatMap(identification.service.createPushSubscription(deviceToken:alerts:))
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
@Published private var mostRecentlyUsedIdentityID: UUID?
|
||||
private let environment: AppEnvironment
|
||||
|
@ -21,9 +38,11 @@ public final class RootViewModel: ObservableObject {
|
|||
userNotificationService = UserNotificationService(environment: environment)
|
||||
self.registerForRemoteNotifications = registerForRemoteNotifications
|
||||
|
||||
allIdentitiesService.mostRecentlyUsedIdentityID.assign(to: &$mostRecentlyUsedIdentityID)
|
||||
allIdentitiesService.immediateMostRecentlyUsedIdentityIDObservation()
|
||||
.replaceError(with: nil)
|
||||
.assign(to: &$mostRecentlyUsedIdentityID)
|
||||
|
||||
newIdentitySelected(id: mostRecentlyUsedIdentityID)
|
||||
identitySelected(id: mostRecentlyUsedIdentityID, immediate: true)
|
||||
|
||||
userNotificationService.isAuthorized()
|
||||
.filter { $0 }
|
||||
|
@ -36,39 +55,8 @@ public final class RootViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
public extension RootViewModel {
|
||||
func newIdentitySelected(id: UUID?) {
|
||||
guard let id = id else {
|
||||
identification = nil
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let identification: Identification
|
||||
|
||||
do {
|
||||
identification = try Identification(service: allIdentitiesService.identityService(id: id))
|
||||
self.identification = identification
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
identification.observationErrors
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in self?.newIdentitySelected(id: self?.mostRecentlyUsedIdentityID ) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
identification.service.updateLastUse()
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
.store(in: &cancellables)
|
||||
|
||||
userNotificationService.isAuthorized()
|
||||
.filter { $0 }
|
||||
.zip(registerForRemoteNotifications())
|
||||
.filter { identification.identity.lastRegisteredDeviceToken != $1 }
|
||||
.map { ($1, identification.identity.pushSubscriptionAlerts) }
|
||||
.flatMap(identification.service.createPushSubscription(deviceToken:alerts:))
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
.store(in: &cancellables)
|
||||
func identitySelected(id: UUID?) {
|
||||
identitySelected(id: id, immediate: false)
|
||||
}
|
||||
|
||||
func deleteIdentity(id: UUID) {
|
||||
|
@ -83,3 +71,33 @@ public extension RootViewModel {
|
|||
instanceFilterService: InstanceFilterService(environment: environment))
|
||||
}
|
||||
}
|
||||
|
||||
private extension RootViewModel {
|
||||
func identitySelected(id: UUID?, immediate: Bool) {
|
||||
guard
|
||||
let id = id,
|
||||
let identityService = try? allIdentitiesService.identityService(id: id) else {
|
||||
identification = nil
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let observation = identityService.observation(immediate: immediate)
|
||||
.catch { [weak self] _ -> Empty<Identity, Never> in
|
||||
DispatchQueue.main.async {
|
||||
self?.identitySelected(id: self?.mostRecentlyUsedIdentityID, immediate: false)
|
||||
}
|
||||
|
||||
return Empty()
|
||||
}
|
||||
.share()
|
||||
|
||||
observation.map {
|
||||
Identification(
|
||||
identity: $0,
|
||||
observation: observation.eraseToAnyPublisher(),
|
||||
service: identityService)
|
||||
}
|
||||
.assign(to: &$identification)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,10 @@ import XCTest
|
|||
|
||||
class AddIdentityViewModelTests: XCTestCase {
|
||||
func testAddIdentity() throws {
|
||||
let sut = AddIdentityViewModel(allIdentitiesService: try AllIdentitiesService(environment: .mock()))
|
||||
let environment = AppEnvironment.mock()
|
||||
let sut = AddIdentityViewModel(
|
||||
allIdentitiesService: try AllIdentitiesService(environment: environment),
|
||||
instanceFilterService: InstanceFilterService(environment: environment))
|
||||
let addedIDRecorder = sut.addedIdentityID.record()
|
||||
|
||||
sut.urlFieldText = "https://mastodon.social"
|
||||
|
@ -22,7 +25,10 @@ class AddIdentityViewModelTests: XCTestCase {
|
|||
}
|
||||
|
||||
func testAddIdentityWithoutScheme() throws {
|
||||
let sut = AddIdentityViewModel(allIdentitiesService: try AllIdentitiesService(environment: .mock()))
|
||||
let environment = AppEnvironment.mock()
|
||||
let sut = AddIdentityViewModel(
|
||||
allIdentitiesService: try AllIdentitiesService(environment: environment),
|
||||
instanceFilterService: InstanceFilterService(environment: environment))
|
||||
let addedIDRecorder = sut.addedIdentityID.record()
|
||||
|
||||
sut.urlFieldText = "mastodon.social"
|
||||
|
@ -32,7 +38,10 @@ class AddIdentityViewModelTests: XCTestCase {
|
|||
}
|
||||
|
||||
func testInvalidURL() throws {
|
||||
let sut = AddIdentityViewModel(allIdentitiesService: try AllIdentitiesService(environment: .mock()))
|
||||
let environment = AppEnvironment.mock()
|
||||
let sut = AddIdentityViewModel(
|
||||
allIdentitiesService: try AllIdentitiesService(environment: environment),
|
||||
instanceFilterService: InstanceFilterService(environment: environment))
|
||||
let recorder = sut.$alertItem.record()
|
||||
|
||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||
|
@ -46,9 +55,10 @@ class AddIdentityViewModelTests: XCTestCase {
|
|||
}
|
||||
|
||||
func testDoesNotAlertCanceledLogin() throws {
|
||||
let allIdentitiesService = try AllIdentitiesService(
|
||||
environment: .mock(webAuthSessionType: CanceledLoginMockWebAuthSession.self))
|
||||
let sut = AddIdentityViewModel(allIdentitiesService: allIdentitiesService)
|
||||
let environment = AppEnvironment.mock(webAuthSessionType: CanceledLoginMockWebAuthSession.self)
|
||||
let sut = AddIdentityViewModel(
|
||||
allIdentitiesService: try AllIdentitiesService(environment: environment),
|
||||
instanceFilterService: InstanceFilterService(environment: environment))
|
||||
let recorder = sut.$alertItem.record()
|
||||
|
||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||
|
|
|
@ -21,7 +21,7 @@ class RootViewModelTests: XCTestCase {
|
|||
let addIdentityViewModel = sut.addIdentityViewModel()
|
||||
|
||||
addIdentityViewModel.addedIdentityID
|
||||
.sink(receiveValue: sut.newIdentitySelected(id:))
|
||||
.sink(receiveValue: sut.identitySelected(id:))
|
||||
.store(in: &cancellables)
|
||||
|
||||
addIdentityViewModel.urlFieldText = "https://mastodon.social"
|
||||
|
|
|
@ -28,7 +28,7 @@ struct AddIdentityView: View {
|
|||
.alertItem($viewModel.alertItem)
|
||||
.onReceive(viewModel.addedIdentityID) { id in
|
||||
withAnimation {
|
||||
rootViewModel.newIdentitySelected(id: id)
|
||||
rootViewModel.identitySelected(id: id)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: viewModel.refreshFilter)
|
||||
|
|
|
@ -41,7 +41,7 @@ private extension IdentitiesView {
|
|||
ForEach(identities) { identity in
|
||||
Button {
|
||||
withAnimation {
|
||||
rootViewModel.newIdentitySelected(id: identity.id)
|
||||
rootViewModel.identitySelected(id: identity.id)
|
||||
}
|
||||
} label: {
|
||||
row(identity: identity)
|
||||
|
|
|
@ -88,7 +88,7 @@ private extension TabNavigationView {
|
|||
.contextMenu(ContextMenu {
|
||||
ForEach(viewModel.recentIdentities) { recentIdentity in
|
||||
Button {
|
||||
rootViewModel.newIdentitySelected(id: recentIdentity.id)
|
||||
rootViewModel.identitySelected(id: recentIdentity.id)
|
||||
} label: {
|
||||
Label(
|
||||
title: { Text(recentIdentity.handle) },
|
||||
|
|
Loading…
Reference in a new issue