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 {
var accountResultRequest: AnyFetchRequest<AccountResult> {
AnyFetchRequest(including(optional: AccountRecord.moved))
var accountResultRequest: QueryInterfaceRequest<AccountResult> {
including(optional: AccountRecord.moved)
.asRequest(of: AccountResult.self)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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