Refactoring

This commit is contained in:
Justin Mazzocchi 2020-09-09 05:05:43 -07:00
parent 7161c21807
commit 2dd1f3ebdd
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
16 changed files with 121 additions and 113 deletions

View file

@ -9,8 +9,8 @@ struct AccountResult: Codable, Hashable, FetchableRecord {
} }
extension QueryInterfaceRequest where RowDecoder == AccountRecord { extension QueryInterfaceRequest where RowDecoder == AccountRecord {
var accountResultRequest: AnyFetchRequest<AccountResult> { var accountResultRequest: QueryInterfaceRequest<AccountResult> {
AnyFetchRequest(including(optional: AccountRecord.moved)) including(optional: AccountRecord.moved)
.asRequest(of: AccountResult.self) .asRequest(of: AccountResult.self)
} }
} }

View file

@ -74,11 +74,11 @@ extension StatusRecord {
through: descendantJoins, through: descendantJoins,
using: StatusContextJoin.status) using: StatusContextJoin.status)
var ancestors: AnyFetchRequest<StatusResult> { var ancestors: QueryInterfaceRequest<StatusResult> {
request(for: Self.ancestors).statusResultRequest request(for: Self.ancestors).statusResultRequest
} }
var descendants: AnyFetchRequest<StatusResult> { var descendants: QueryInterfaceRequest<StatusResult> {
request(for: Self.descendants).statusResultRequest request(for: Self.descendants).statusResultRequest
} }

View file

@ -25,12 +25,12 @@ extension StatusResult {
} }
extension QueryInterfaceRequest where RowDecoder == StatusRecord { extension QueryInterfaceRequest where RowDecoder == StatusRecord {
var statusResultRequest: AnyFetchRequest<StatusResult> { var statusResultRequest: QueryInterfaceRequest<StatusResult> {
AnyFetchRequest(including(required: StatusRecord.account) including(required: StatusRecord.account)
.including(optional: StatusRecord.accountMoved) .including(optional: StatusRecord.accountMoved)
.including(optional: StatusRecord.reblogAccount) .including(optional: StatusRecord.reblogAccount)
.including(optional: StatusRecord.reblogAccountMoved) .including(optional: StatusRecord.reblogAccountMoved)
.including(optional: StatusRecord.reblog)) .including(optional: StatusRecord.reblog)
.asRequest(of: StatusResult.self) .asRequest(of: StatusResult.self)
} }
} }

View file

@ -14,7 +14,7 @@ extension Identity {
instance: result.instance, instance: result.instance,
account: result.account, account: result.account,
lastRegisteredDeviceToken: result.identity.lastRegisteredDeviceToken, lastRegisteredDeviceToken: result.identity.lastRegisteredDeviceToken,
pushSubscriptionAlerts: result.pushSubscriptionAlerts) pushSubscriptionAlerts: result.identity.pushSubscriptionAlerts)
} }
} }

View file

@ -41,7 +41,7 @@ extension Timeline {
using: TimelineStatusJoin.status) using: TimelineStatusJoin.status)
.order(Column("createdAt").desc) .order(Column("createdAt").desc)
var statuses: AnyFetchRequest<StatusResult> { var statuses: QueryInterfaceRequest<StatusResult> {
request(for: Self.statuses).statusResultRequest request(for: Self.statuses).statusResultRequest
} }
} }

View file

@ -141,16 +141,14 @@ public extension IdentityDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func identityObservation(id: UUID) -> AnyPublisher<Identity, Error> { func identityObservation(id: UUID, immediate: Bool) -> AnyPublisher<Identity, Error> {
ValueObservation.tracking( ValueObservation.tracking(
IdentityRecord IdentityRecord
.filter(Column("id") == id) .filter(Column("id") == id)
.including(optional: IdentityRecord.instance) .identityResultRequest
.including(optional: IdentityRecord.account)
.asRequest(of: IdentityResult.self)
.fetchOne) .fetchOne)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseQueue, scheduling: .immediate) .publisher(in: databaseQueue, scheduling: immediate ? .immediate : .async(onQueue: .main))
.tryMap { .tryMap {
guard let result = $0 else { throw IdentityDatabaseError.identityNotFound } guard let result = $0 else { throw IdentityDatabaseError.identityNotFound }
@ -160,7 +158,11 @@ public extension IdentityDatabase {
} }
func identitiesObservation() -> AnyPublisher<[Identity], Error> { func identitiesObservation() -> AnyPublisher<[Identity], Error> {
ValueObservation.tracking(Self.identitiesRequest().fetchAll) ValueObservation.tracking(
IdentityRecord
.order(Column("lastUsedAt").desc)
.identityResultRequest
.fetchAll)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseQueue) .publisher(in: databaseQueue)
.map { $0.map(Identity.init(result:)) } .map { $0.map(Identity.init(result:)) }
@ -169,7 +171,9 @@ public extension IdentityDatabase {
func recentIdentitiesObservation(excluding: UUID) -> AnyPublisher<[Identity], Error> { func recentIdentitiesObservation(excluding: UUID) -> AnyPublisher<[Identity], Error> {
ValueObservation.tracking( ValueObservation.tracking(
Self.identitiesRequest() IdentityRecord
.order(Column("lastUsedAt").desc)
.identityResultRequest
.filter(Column("id") != excluding) .filter(Column("id") != excluding)
.limit(9) .limit(9)
.fetchAll) .fetchAll)
@ -179,7 +183,7 @@ public extension IdentityDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func mostRecentlyUsedIdentityIDObservation() -> AnyPublisher<UUID?, Error> { func immediateMostRecentlyUsedIdentityIDObservation() -> AnyPublisher<UUID?, Error> {
ValueObservation.tracking(IdentityRecord.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne) ValueObservation.tracking(IdentityRecord.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne)
.removeDuplicates() .removeDuplicates()
.publisher(in: databaseQueue, scheduling: .immediate) .publisher(in: databaseQueue, scheduling: .immediate)
@ -188,7 +192,9 @@ public extension IdentityDatabase {
func identitiesWithOutdatedDeviceTokens(deviceToken: Data) -> AnyPublisher<[Identity], Error> { func identitiesWithOutdatedDeviceTokens(deviceToken: Data) -> AnyPublisher<[Identity], Error> {
databaseQueue.readPublisher( databaseQueue.readPublisher(
value: Self.identitiesRequest() value: IdentityRecord
.order(Column("lastUsedAt").desc)
.identityResultRequest
.filter(Column("lastRegisteredDeviceToken") != deviceToken) .filter(Column("lastRegisteredDeviceToken") != deviceToken)
.fetchAll) .fetchAll)
.map { $0.map(Identity.init(result:)) } .map { $0.map(Identity.init(result:)) }
@ -199,14 +205,6 @@ public extension IdentityDatabase {
private extension IdentityDatabase { private extension IdentityDatabase {
private static let name = "Identity" 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 { private static func writePreferences(_ preferences: Identity.Preferences, id: UUID) -> (Database) throws -> Void {
{ {
let data = try IdentityRecord.databaseJSONEncoder(for: "preferences").encode(preferences) let data = try IdentityRecord.databaseJSONEncoder(for: "preferences").encode(preferences)

View file

@ -8,5 +8,12 @@ struct IdentityResult: Codable, Hashable, FetchableRecord {
let identity: IdentityRecord let identity: IdentityRecord
let instance: Identity.Instance? let instance: Identity.Instance?
let account: Identity.Account? 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)
}
} }

View file

@ -8,8 +8,6 @@ import MastodonAPI
import Secrets import Secrets
public struct AllIdentitiesService { public struct AllIdentitiesService {
public let mostRecentlyUsedIdentityID: AnyPublisher<UUID?, Never>
private let environment: AppEnvironment private let environment: AppEnvironment
private let database: IdentityDatabase private let database: IdentityDatabase
@ -18,10 +16,6 @@ public struct AllIdentitiesService {
self.database = try environment.fixtureDatabase ?? IdentityDatabase( self.database = try environment.fixtureDatabase ?? IdentityDatabase(
inMemory: environment.inMemoryContent, inMemory: environment.inMemoryContent,
keychain: environment.keychain) 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) 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> { func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
let secrets = Secrets(identityID: id, keychain: environment.keychain) let secrets = Secrets(identityID: id, keychain: environment.keychain)

View file

@ -15,7 +15,6 @@ public struct IdentityService {
private let environment: AppEnvironment private let environment: AppEnvironment
private let mastodonAPIClient: MastodonAPIClient private let mastodonAPIClient: MastodonAPIClient
private let secrets: Secrets private let secrets: Secrets
private let observationErrorsInput = PassthroughSubject<Error, Never>()
init(id: UUID, database: IdentityDatabase, environment: AppEnvironment) throws { init(id: UUID, database: IdentityDatabase, environment: AppEnvironment) throws {
identityID = id identityID = id
@ -86,8 +85,8 @@ public extension IdentityService {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func observation() -> AnyPublisher<Identity, Error> { func observation(immediate: Bool) -> AnyPublisher<Identity, Error> {
identityDatabase.identityObservation(id: identityID) identityDatabase.identityObservation(id: identityID, immediate: immediate)
} }
func listsObservation() -> AnyPublisher<[Timeline], Error> { func listsObservation() -> AnyPublisher<[Timeline], Error> {

View file

@ -4,38 +4,16 @@ import Combine
import Foundation import Foundation
import ServiceLayer import ServiceLayer
enum IdentificationError: Error {
case initialIdentityValueAbsent
}
public final class Identification: ObservableObject { public final class Identification: ObservableObject {
@Published private(set) var identity: Identity @Published private(set) var identity: Identity
let service: IdentityService 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.identity = identity
self.service = service
let observationErrorsSubject = PassthroughSubject<Error, Never>() DispatchQueue.main.async {
observation.dropFirst().assign(to: &self.$identity)
observationErrors = observationErrorsSubject.eraseToAnyPublisher()
sharedObservation.catch { error -> Empty<Identity, Never> in
observationErrorsSubject.send(error)
return Empty()
} }
.assign(to: &$identity)
} }
} }

View file

@ -5,7 +5,24 @@ import Foundation
import ServiceLayer import ServiceLayer
public final class RootViewModel: ObservableObject { 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? @Published private var mostRecentlyUsedIdentityID: UUID?
private let environment: AppEnvironment private let environment: AppEnvironment
@ -21,9 +38,11 @@ public final class RootViewModel: ObservableObject {
userNotificationService = UserNotificationService(environment: environment) userNotificationService = UserNotificationService(environment: environment)
self.registerForRemoteNotifications = registerForRemoteNotifications 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() userNotificationService.isAuthorized()
.filter { $0 } .filter { $0 }
@ -36,39 +55,8 @@ public final class RootViewModel: ObservableObject {
} }
public extension RootViewModel { public extension RootViewModel {
func newIdentitySelected(id: UUID?) { func identitySelected(id: UUID?) {
guard let id = id else { identitySelected(id: id, immediate: false)
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 deleteIdentity(id: UUID) { func deleteIdentity(id: UUID) {
@ -83,3 +71,33 @@ public extension RootViewModel {
instanceFilterService: InstanceFilterService(environment: environment)) 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)
}
}

View file

@ -12,7 +12,10 @@ import XCTest
class AddIdentityViewModelTests: XCTestCase { class AddIdentityViewModelTests: XCTestCase {
func testAddIdentity() throws { 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() let addedIDRecorder = sut.addedIdentityID.record()
sut.urlFieldText = "https://mastodon.social" sut.urlFieldText = "https://mastodon.social"
@ -22,7 +25,10 @@ class AddIdentityViewModelTests: XCTestCase {
} }
func testAddIdentityWithoutScheme() throws { 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() let addedIDRecorder = sut.addedIdentityID.record()
sut.urlFieldText = "mastodon.social" sut.urlFieldText = "mastodon.social"
@ -32,7 +38,10 @@ class AddIdentityViewModelTests: XCTestCase {
} }
func testInvalidURL() throws { 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() let recorder = sut.$alertItem.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
@ -46,9 +55,10 @@ class AddIdentityViewModelTests: XCTestCase {
} }
func testDoesNotAlertCanceledLogin() throws { func testDoesNotAlertCanceledLogin() throws {
let allIdentitiesService = try AllIdentitiesService( let environment = AppEnvironment.mock(webAuthSessionType: CanceledLoginMockWebAuthSession.self)
environment: .mock(webAuthSessionType: CanceledLoginMockWebAuthSession.self)) let sut = AddIdentityViewModel(
let sut = AddIdentityViewModel(allIdentitiesService: allIdentitiesService) allIdentitiesService: try AllIdentitiesService(environment: environment),
instanceFilterService: InstanceFilterService(environment: environment))
let recorder = sut.$alertItem.record() let recorder = sut.$alertItem.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) XCTAssertNil(try wait(for: recorder.next(), timeout: 1))

View file

@ -21,7 +21,7 @@ class RootViewModelTests: XCTestCase {
let addIdentityViewModel = sut.addIdentityViewModel() let addIdentityViewModel = sut.addIdentityViewModel()
addIdentityViewModel.addedIdentityID addIdentityViewModel.addedIdentityID
.sink(receiveValue: sut.newIdentitySelected(id:)) .sink(receiveValue: sut.identitySelected(id:))
.store(in: &cancellables) .store(in: &cancellables)
addIdentityViewModel.urlFieldText = "https://mastodon.social" addIdentityViewModel.urlFieldText = "https://mastodon.social"

View file

@ -28,7 +28,7 @@ struct AddIdentityView: View {
.alertItem($viewModel.alertItem) .alertItem($viewModel.alertItem)
.onReceive(viewModel.addedIdentityID) { id in .onReceive(viewModel.addedIdentityID) { id in
withAnimation { withAnimation {
rootViewModel.newIdentitySelected(id: id) rootViewModel.identitySelected(id: id)
} }
} }
.onAppear(perform: viewModel.refreshFilter) .onAppear(perform: viewModel.refreshFilter)

View file

@ -41,7 +41,7 @@ private extension IdentitiesView {
ForEach(identities) { identity in ForEach(identities) { identity in
Button { Button {
withAnimation { withAnimation {
rootViewModel.newIdentitySelected(id: identity.id) rootViewModel.identitySelected(id: identity.id)
} }
} label: { } label: {
row(identity: identity) row(identity: identity)

View file

@ -88,7 +88,7 @@ private extension TabNavigationView {
.contextMenu(ContextMenu { .contextMenu(ContextMenu {
ForEach(viewModel.recentIdentities) { recentIdentity in ForEach(viewModel.recentIdentities) { recentIdentity in
Button { Button {
rootViewModel.newIdentitySelected(id: recentIdentity.id) rootViewModel.identitySelected(id: recentIdentity.id)
} label: { } label: {
Label( Label(
title: { Text(recentIdentity.handle) }, title: { Text(recentIdentity.handle) },