Startup and syncing
This commit is contained in:
parent
c7e4186749
commit
ed82cffd91
|
@ -105,6 +105,11 @@ extension ContentDatabase {
|
|||
t.column("wholeWord", .boolean).notNull()
|
||||
}
|
||||
|
||||
try db.create(table: "lastReadIdRecord") { t in
|
||||
t.column("markerTimeline", .text).primaryKey(onConflict: .replace)
|
||||
t.column("id", .text).notNull()
|
||||
}
|
||||
|
||||
try db.create(table: "statusAncestorJoin") { t in
|
||||
t.column("parentId", .text).indexed().notNull()
|
||||
.references("statusRecord", onDelete: .cascade)
|
||||
|
|
|
@ -7,12 +7,17 @@ import Keychain
|
|||
import Mastodon
|
||||
import Secrets
|
||||
|
||||
// swiftlint:disable file_length
|
||||
public struct ContentDatabase {
|
||||
public let activeFiltersPublisher: AnyPublisher<[Filter], Error>
|
||||
|
||||
private let databaseWriter: DatabaseWriter
|
||||
|
||||
public init(id: Identity.Id, inMemory: Bool, keychain: Keychain.Type) throws {
|
||||
public init(id: Identity.Id,
|
||||
useHomeTimelineLastReadId: Bool,
|
||||
useNotificationsLastReadId: Bool,
|
||||
inMemory: Bool,
|
||||
keychain: Keychain.Type) throws {
|
||||
if inMemory {
|
||||
databaseWriter = DatabaseQueue()
|
||||
} else {
|
||||
|
@ -27,7 +32,10 @@ public struct ContentDatabase {
|
|||
}
|
||||
|
||||
try Self.migrator.migrate(databaseWriter)
|
||||
try Self.clean(databaseWriter)
|
||||
try Self.clean(
|
||||
databaseWriter,
|
||||
useHomeTimelineLastReadId: useHomeTimelineLastReadId,
|
||||
useNotificationsLastReadId: useNotificationsLastReadId)
|
||||
|
||||
activeFiltersPublisher = ValueObservation.tracking {
|
||||
try Filter.filter(Filter.Columns.expiresAt == nil || Filter.Columns.expiresAt > Date()).fetchAll($0)
|
||||
|
@ -278,6 +286,12 @@ public extension ContentDatabase {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func setLastReadId(_ id: String, markerTimeline: Marker.Timeline) -> AnyPublisher<Never, Error> {
|
||||
databaseWriter.writePublisher(updates: LastReadIdRecord(markerTimeline: markerTimeline, id: id).save)
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> {
|
||||
ValueObservation.tracking(
|
||||
TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne)
|
||||
|
@ -331,19 +345,64 @@ public extension ContentDatabase {
|
|||
.publisher(in: databaseWriter)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func lastReadId(_ markerTimeline: Marker.Timeline) -> String? {
|
||||
try? databaseWriter.read {
|
||||
try String.fetchOne(
|
||||
$0,
|
||||
LastReadIdRecord.filter(LastReadIdRecord.Columns.markerTimeline == markerTimeline.rawValue)
|
||||
.select(LastReadIdRecord.Columns.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ContentDatabase {
|
||||
static let cleanAfterLastReadIdCount = 40
|
||||
static func fileURL(id: Identity.Id) throws -> URL {
|
||||
try FileManager.default.databaseDirectoryURL(name: id.uuidString)
|
||||
}
|
||||
|
||||
static func clean(_ databaseWriter: DatabaseWriter) throws {
|
||||
static func clean(_ databaseWriter: DatabaseWriter,
|
||||
useHomeTimelineLastReadId: Bool,
|
||||
useNotificationsLastReadId: Bool) throws {
|
||||
try databaseWriter.write {
|
||||
try TimelineRecord.deleteAll($0)
|
||||
try StatusRecord.deleteAll($0)
|
||||
try AccountRecord.deleteAll($0)
|
||||
if useHomeTimelineLastReadId {
|
||||
try TimelineRecord.filter(TimelineRecord.Columns.id != Timeline.home.id).deleteAll($0)
|
||||
var statusIds = try Status.Id.fetchAll(
|
||||
$0,
|
||||
TimelineStatusJoin.select(TimelineStatusJoin.Columns.statusId)
|
||||
.order(TimelineStatusJoin.Columns.statusId.desc))
|
||||
|
||||
if let lastReadId = try Status.Id.fetchOne(
|
||||
$0,
|
||||
LastReadIdRecord.filter(LastReadIdRecord.Columns.markerTimeline == Marker.Timeline.home.rawValue)
|
||||
.select(LastReadIdRecord.Columns.id))
|
||||
?? statusIds.first,
|
||||
let index = statusIds.firstIndex(of: lastReadId) {
|
||||
statusIds = Array(statusIds.prefix(index + Self.cleanAfterLastReadIdCount))
|
||||
}
|
||||
|
||||
statusIds += try Status.Id.fetchAll(
|
||||
$0,
|
||||
StatusRecord.filter(statusIds.contains(StatusRecord.Columns.id)
|
||||
&& StatusRecord.Columns.reblogId != nil)
|
||||
.select(StatusRecord.Columns.reblogId))
|
||||
try StatusRecord.filter(!statusIds.contains(StatusRecord.Columns.id) ).deleteAll($0)
|
||||
var accountIds = try Account.Id.fetchAll($0, StatusRecord.select(StatusRecord.Columns.accountId))
|
||||
accountIds += try Account.Id.fetchAll(
|
||||
$0,
|
||||
AccountRecord.filter(accountIds.contains(AccountRecord.Columns.id)
|
||||
&& AccountRecord.Columns.movedId != nil)
|
||||
.select(AccountRecord.Columns.movedId))
|
||||
try AccountRecord.filter(!accountIds.contains(AccountRecord.Columns.id)).deleteAll($0)
|
||||
} else {
|
||||
try TimelineRecord.deleteAll($0)
|
||||
try StatusRecord.deleteAll($0)
|
||||
try AccountRecord.deleteAll($0)
|
||||
}
|
||||
|
||||
try AccountList.deleteAll($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
// swiftlint:enable file_length
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Mastodon
|
||||
|
||||
struct LastReadIdRecord: ContentDatabaseRecord, Hashable {
|
||||
let markerTimeline: Marker.Timeline
|
||||
let id: String
|
||||
}
|
||||
|
||||
extension LastReadIdRecord {
|
||||
enum Columns {
|
||||
static let markerTimeline = Column(LastReadIdRecord.CodingKeys.markerTimeline)
|
||||
static let id = Column(LastReadIdRecord.CodingKeys.id)
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ public protocol Target {
|
|||
var baseURL: URL { get }
|
||||
var pathComponents: [String] { get }
|
||||
var method: HTTPMethod { get }
|
||||
var queryParameters: [String: String]? { get }
|
||||
var queryParameters: [URLQueryItem] { get }
|
||||
var jsonBody: [String: Any]? { get }
|
||||
var headers: [String: String]? { get }
|
||||
}
|
||||
|
@ -19,9 +19,8 @@ public extension Target {
|
|||
url.appendPathComponent(pathComponent)
|
||||
}
|
||||
|
||||
if var components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let queryItems = queryParameters?.map(URLQueryItem.init(name:value:)) {
|
||||
components.queryItems = queryItems
|
||||
if var components = URLComponents(url: url, resolvingAgainstBaseURL: true), !queryParameters.isEmpty {
|
||||
components.queryItems = queryParameters
|
||||
|
||||
if let queryComponentURL = components.url {
|
||||
url = queryComponentURL
|
||||
|
|
|
@ -67,6 +67,13 @@
|
|||
"preferences.notification-types.reblog" = "Reblog";
|
||||
"preferences.notification-types.mention" = "Mention";
|
||||
"preferences.notification-types.poll" = "Poll";
|
||||
"preferences.startup-and-syncing" = "Startup and Syncing";
|
||||
"preferences.startup-and-syncing.home-timeline" = "Home timeline";
|
||||
"preferences.startup-and-syncing.notifications-tab" = "Notifications tab";
|
||||
"preferences.startup-and-syncing.position-on-startup" = "Position on startup";
|
||||
"preferences.startup-and-syncing.remember-position" = "Remember position";
|
||||
"preferences.startup-and-syncing.sync-position" = "Sync position with web and other devices";
|
||||
"preferences.startup-and-syncing.newest" = "Load newest";
|
||||
"filters.active" = "Active";
|
||||
"filters.expired" = "Expired";
|
||||
"filter.add-new" = "Add New Filter";
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct Marker: Codable, Hashable {
|
||||
public let lastReadId: String
|
||||
public let updatedAt: Date
|
||||
public let version: Int
|
||||
}
|
||||
|
||||
public extension Marker {
|
||||
enum Timeline: String, Codable {
|
||||
case home
|
||||
case notifications
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ public protocol Endpoint {
|
|||
var context: [String] { get }
|
||||
var pathComponentsInContext: [String] { get }
|
||||
var method: HTTPMethod { get }
|
||||
var queryParameters: [String: String]? { get }
|
||||
var queryParameters: [URLQueryItem] { get }
|
||||
var jsonBody: [String: Any]? { get }
|
||||
var headers: [String: String]? { get }
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ public extension Endpoint {
|
|||
context + pathComponentsInContext
|
||||
}
|
||||
|
||||
var queryParameters: [String: String]? { nil }
|
||||
var queryParameters: [URLQueryItem] { [] }
|
||||
|
||||
var jsonBody: [String: Any]? { nil }
|
||||
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import HTTP
|
||||
import Mastodon
|
||||
|
||||
public enum MarkersEndpoint {
|
||||
case get(Set<Marker.Timeline>)
|
||||
case post([Marker.Timeline: String])
|
||||
}
|
||||
|
||||
extension MarkersEndpoint: Endpoint {
|
||||
public typealias ResultType = [String: Marker]
|
||||
|
||||
public var pathComponentsInContext: [String] {
|
||||
["markers"]
|
||||
}
|
||||
|
||||
public var queryParameters: [URLQueryItem] {
|
||||
switch self {
|
||||
case let .get(timelines):
|
||||
return Array(timelines).map { URLQueryItem(name: "timeline[]", value: $0.rawValue) }
|
||||
case .post:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public var jsonBody: [String: Any]? {
|
||||
switch self {
|
||||
case .get:
|
||||
return nil
|
||||
case let .post(lastReadIds):
|
||||
return Dictionary(uniqueKeysWithValues: lastReadIds.map { ($0.rawValue, ["last_read_id": $1]) })
|
||||
}
|
||||
}
|
||||
|
||||
public var method: HTTPMethod {
|
||||
switch self {
|
||||
case .get:
|
||||
return .get
|
||||
case .post:
|
||||
return .post
|
||||
}
|
||||
}
|
||||
}
|
|
@ -31,15 +31,23 @@ extension Paged: Endpoint {
|
|||
|
||||
public var method: HTTPMethod { endpoint.method }
|
||||
|
||||
public var queryParameters: [String: String]? {
|
||||
var queryParameters = endpoint.queryParameters ?? [String: String]()
|
||||
public var queryParameters: [URLQueryItem] {
|
||||
var queryParameters = endpoint.queryParameters
|
||||
|
||||
queryParameters["max_id"] = maxId
|
||||
queryParameters["min_id"] = minId
|
||||
queryParameters["since_id"] = sinceId
|
||||
if let maxId = maxId {
|
||||
queryParameters.append(.init(name: "max_id", value: maxId))
|
||||
}
|
||||
|
||||
if let minId = minId {
|
||||
queryParameters.append(.init(name: "min_id", value: minId))
|
||||
}
|
||||
|
||||
if let sinceId = sinceId {
|
||||
queryParameters.append(.init(name: "since_id", value: sinceId))
|
||||
}
|
||||
|
||||
if let limit = limit {
|
||||
queryParameters["limit"] = String(limit)
|
||||
queryParameters.append(.init(name: "limit", value: String(limit)))
|
||||
}
|
||||
|
||||
return queryParameters
|
||||
|
|
|
@ -32,13 +32,13 @@ extension ResultsEndpoint: Endpoint {
|
|||
}
|
||||
}
|
||||
|
||||
public var queryParameters: [String: String]? {
|
||||
public var queryParameters: [URLQueryItem] {
|
||||
switch self {
|
||||
case let .search(query, resolve):
|
||||
var params = ["q": query]
|
||||
var params = [URLQueryItem(name: "q", value: query)]
|
||||
|
||||
if resolve {
|
||||
params["resolve"] = String(true)
|
||||
params.append(.init(name: "resolve", value: "true"))
|
||||
}
|
||||
|
||||
return params
|
||||
|
|
|
@ -39,16 +39,16 @@ extension StatusesEndpoint: Endpoint {
|
|||
}
|
||||
}
|
||||
|
||||
public var queryParameters: [String: String]? {
|
||||
public var queryParameters: [URLQueryItem] {
|
||||
switch self {
|
||||
case let .timelinesPublic(local):
|
||||
return ["local": String(local)]
|
||||
return [URLQueryItem(name: "local", value: String(local))]
|
||||
case let .accountsStatuses(_, excludeReplies, onlyMedia, pinned):
|
||||
return ["exclude_replies": String(excludeReplies),
|
||||
"only_media": String(onlyMedia),
|
||||
"pinned": String(pinned)]
|
||||
return [URLQueryItem(name: "exclude_replies", value: String(excludeReplies)),
|
||||
URLQueryItem(name: "only_media", value: String(onlyMedia)),
|
||||
URLQueryItem(name: "pinned", value: String(pinned))]
|
||||
default:
|
||||
return nil
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ extension MastodonAPITarget: DecodableTarget {
|
|||
|
||||
public var method: HTTPMethod { endpoint.method }
|
||||
|
||||
public var queryParameters: [String: String]? { endpoint.queryParameters }
|
||||
public var queryParameters: [URLQueryItem] { endpoint.queryParameters }
|
||||
|
||||
public var jsonBody: [String: Any]? { endpoint.jsonBody }
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */; };
|
||||
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; };
|
||||
D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03B1B29253818F3008F964B /* MediaPreferencesView.swift */; };
|
||||
D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */; };
|
||||
D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; };
|
||||
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; };
|
||||
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; };
|
||||
|
@ -118,6 +119,7 @@
|
|||
D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsView.swift; sourceTree = "<group>"; };
|
||||
D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; };
|
||||
D03B1B29253818F3008F964B /* MediaPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreferencesView.swift; sourceTree = "<group>"; };
|
||||
D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAndSyncingPreferencesView.swift; sourceTree = "<group>"; };
|
||||
D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D0625E58250F092900502611 /* StatusListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListCell.swift; sourceTree = "<group>"; };
|
||||
D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = "<group>"; };
|
||||
|
@ -348,6 +350,7 @@
|
|||
D0C7D42724F76169001EBDBB /* RootView.swift */,
|
||||
D02E1F94250B13210071AD56 /* SafariView.swift */,
|
||||
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
|
||||
D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */,
|
||||
D0625E55250F086B00502611 /* Status */,
|
||||
D0C7D42524F76169001EBDBB /* TableView.swift */,
|
||||
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */,
|
||||
|
@ -632,6 +635,7 @@
|
|||
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
|
||||
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
|
||||
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */,
|
||||
D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */,
|
||||
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */,
|
||||
D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */,
|
||||
D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */,
|
||||
|
|
|
@ -8,6 +8,7 @@ public protocol CollectionService {
|
|||
var nextPageMaxId: AnyPublisher<String, Never> { get }
|
||||
var title: AnyPublisher<String, Never> { get }
|
||||
var navigationService: NavigationService { get }
|
||||
var markerTimeline: Marker.Timeline? { get }
|
||||
func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error>
|
||||
}
|
||||
|
||||
|
@ -15,4 +16,6 @@ extension CollectionService {
|
|||
public var nextPageMaxId: AnyPublisher<String, Never> { Empty().eraseToAnyPublisher() }
|
||||
|
||||
public var title: AnyPublisher<String, Never> { Empty().eraseToAnyPublisher() }
|
||||
|
||||
public var markerTimeline: Marker.Timeline? { nil }
|
||||
}
|
||||
|
|
|
@ -27,9 +27,14 @@ public struct IdentityService {
|
|||
instanceURL: try secrets.getInstanceURL())
|
||||
mastodonAPIClient.accessToken = try? secrets.getAccessToken()
|
||||
|
||||
contentDatabase = try ContentDatabase(id: id,
|
||||
inMemory: environment.inMemoryContent,
|
||||
keychain: environment.keychain)
|
||||
let appPreferences = AppPreferences(environment: environment)
|
||||
|
||||
contentDatabase = try ContentDatabase(
|
||||
id: id,
|
||||
useHomeTimelineLastReadId: appPreferences.homeTimelineBehavior == .rememberPosition,
|
||||
useNotificationsLastReadId: appPreferences.notificationsTabBehavior == .rememberPosition,
|
||||
inMemory: environment.inMemoryContent,
|
||||
keychain: environment.keychain)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,6 +92,29 @@ public extension IdentityService {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func getMarker(_ markerTimeline: Marker.Timeline) -> AnyPublisher<Marker, Error> {
|
||||
mastodonAPIClient.request(MarkersEndpoint.get([markerTimeline]))
|
||||
.compactMap { $0[markerTimeline.rawValue] }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func getLocalLastReadId(_ markerTimeline: Marker.Timeline) -> String? {
|
||||
contentDatabase.lastReadId(markerTimeline)
|
||||
}
|
||||
|
||||
func setLastReadId(_ id: String, forMarker markerTimeline: Marker.Timeline) -> AnyPublisher<Never, Error> {
|
||||
switch AppPreferences(environment: environment).positionBehavior(markerTimeline: markerTimeline) {
|
||||
case .rememberPosition:
|
||||
return contentDatabase.setLastReadId(id, markerTimeline: markerTimeline)
|
||||
case .syncPosition:
|
||||
return mastodonAPIClient.request(MarkersEndpoint.post([markerTimeline: id]))
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
case .newest:
|
||||
return Empty().eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
func identityPublisher(immediate: Bool) -> AnyPublisher<Identity, Error> {
|
||||
identityDatabase.identityPublisher(id: id, immediate: immediate)
|
||||
}
|
||||
|
|
|
@ -73,7 +73,7 @@ private struct UpdatedFilterTarget: DecodableTarget {
|
|||
let baseURL = URL(string: "https://filter.metabolist.com")!
|
||||
let pathComponents = ["filter"]
|
||||
let method = HTTPMethod.get
|
||||
let queryParameters: [String: String]? = nil
|
||||
let queryParameters: [URLQueryItem] = []
|
||||
let jsonBody: [String: Any]? = nil
|
||||
let headers: [String: String]? = nil
|
||||
}
|
||||
|
|
|
@ -15,15 +15,27 @@ public struct TimelineService {
|
|||
private let timeline: Timeline
|
||||
private let mastodonAPIClient: MastodonAPIClient
|
||||
private let contentDatabase: ContentDatabase
|
||||
private let nextPageMaxIdSubject = PassthroughSubject<String, Never>()
|
||||
private let nextPageMaxIdSubject: CurrentValueSubject<String, Never>
|
||||
|
||||
init(timeline: Timeline, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
||||
self.timeline = timeline
|
||||
self.mastodonAPIClient = mastodonAPIClient
|
||||
self.contentDatabase = contentDatabase
|
||||
|
||||
let nextPageMaxIdSubject = CurrentValueSubject<String, Never>(String(Int.max))
|
||||
|
||||
self.nextPageMaxIdSubject = nextPageMaxIdSubject
|
||||
sections = contentDatabase.timelinePublisher(timeline)
|
||||
.handleEvents(receiveOutput: {
|
||||
guard case let .status(status, _) = $0.last?.last,
|
||||
status.id < nextPageMaxIdSubject.value
|
||||
else { return }
|
||||
|
||||
nextPageMaxIdSubject.send(status.id)
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
|
||||
nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher()
|
||||
nextPageMaxId = nextPageMaxIdSubject.dropFirst().eraseToAnyPublisher()
|
||||
|
||||
if case let .tag(tag) = timeline {
|
||||
title = Just("#".appending(tag)).eraseToAnyPublisher()
|
||||
|
@ -34,10 +46,19 @@ public struct TimelineService {
|
|||
}
|
||||
|
||||
extension TimelineService: CollectionService {
|
||||
public var markerTimeline: Marker.Timeline? {
|
||||
switch timeline {
|
||||
case .home:
|
||||
return .home
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> {
|
||||
mastodonAPIClient.pagedRequest(timeline.endpoint, maxId: maxId, minId: minId)
|
||||
.handleEvents(receiveOutput: {
|
||||
guard let maxId = $0.info.maxId else { return }
|
||||
guard let maxId = $0.info.maxId, maxId < nextPageMaxIdSubject.value else { return }
|
||||
|
||||
nextPageMaxIdSubject.send(maxId)
|
||||
})
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import CodableBloomFilter
|
||||
import Foundation
|
||||
import Mastodon
|
||||
|
||||
public struct AppPreferences {
|
||||
private let userDefaults: UserDefaults
|
||||
|
@ -30,6 +31,14 @@ public extension AppPreferences {
|
|||
public var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum PositionBehavior: String, CaseIterable, Identifiable {
|
||||
case rememberPosition
|
||||
case syncPosition
|
||||
case newest
|
||||
|
||||
public var id: String { rawValue }
|
||||
}
|
||||
|
||||
var useSystemReduceMotionForMedia: Bool {
|
||||
get { self[.useSystemReduceMotionForMedia] ?? true }
|
||||
set { self[.useSystemReduceMotionForMedia] = newValue }
|
||||
|
@ -76,9 +85,42 @@ public extension AppPreferences {
|
|||
set { self[.autoplayVideos] = newValue.rawValue }
|
||||
}
|
||||
|
||||
var homeTimelineBehavior: PositionBehavior {
|
||||
get {
|
||||
if let rawValue = self[.homeTimelineBehavior] as String?,
|
||||
let value = PositionBehavior(rawValue: rawValue) {
|
||||
return value
|
||||
}
|
||||
|
||||
return .rememberPosition
|
||||
}
|
||||
set { self[.homeTimelineBehavior] = newValue.rawValue }
|
||||
}
|
||||
|
||||
var notificationsTabBehavior: PositionBehavior {
|
||||
get {
|
||||
if let rawValue = self[.notificationsTabBehavior] as String?,
|
||||
let value = PositionBehavior(rawValue: rawValue) {
|
||||
return value
|
||||
}
|
||||
|
||||
return .newest
|
||||
}
|
||||
set { self[.notificationsTabBehavior] = newValue.rawValue }
|
||||
}
|
||||
|
||||
var shouldReduceMotion: Bool {
|
||||
systemReduceMotion() && useSystemReduceMotionForMedia
|
||||
}
|
||||
|
||||
func positionBehavior(markerTimeline: Marker.Timeline) -> PositionBehavior {
|
||||
switch markerTimeline {
|
||||
case .home:
|
||||
return homeTimelineBehavior
|
||||
case .notifications:
|
||||
return notificationsTabBehavior
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppPreferences {
|
||||
|
@ -103,6 +145,8 @@ private extension AppPreferences {
|
|||
case animateHeaders
|
||||
case autoplayGIFs
|
||||
case autoplayVideos
|
||||
case homeTimelineBehavior
|
||||
case notificationsTabBehavior
|
||||
}
|
||||
|
||||
subscript<T>(index: Item) -> T? {
|
||||
|
|
|
@ -6,6 +6,7 @@ import SafariServices
|
|||
import SwiftUI
|
||||
import ViewModels
|
||||
|
||||
// swiftlint:disable file_length
|
||||
class TableViewController: UITableViewController {
|
||||
var transitionViewTag = -1
|
||||
|
||||
|
@ -272,13 +273,16 @@ private extension TableViewController {
|
|||
if
|
||||
let item = update.maintainScrollPosition,
|
||||
let indexPath = self.dataSource.indexPath(for: item) {
|
||||
self.tableView.contentInset.bottom = max(
|
||||
0,
|
||||
self.tableView.frame.height
|
||||
- self.tableView.contentSize.height
|
||||
- self.tableView.safeAreaInsets.top
|
||||
- self.tableView.safeAreaInsets.bottom)
|
||||
+ self.tableView.rectForRow(at: indexPath).minY
|
||||
if self.viewModel.shouldAdjustContentInset {
|
||||
self.tableView.contentInset.bottom = max(
|
||||
0,
|
||||
self.tableView.frame.height
|
||||
- self.tableView.contentSize.height
|
||||
- self.tableView.safeAreaInsets.top
|
||||
- self.tableView.safeAreaInsets.bottom)
|
||||
+ self.tableView.rectForRow(at: indexPath).minY
|
||||
}
|
||||
|
||||
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
|
||||
|
||||
if let offsetFromNavigationBar = offsetFromNavigationBar {
|
||||
|
@ -399,3 +403,4 @@ private extension TableViewController {
|
|||
}
|
||||
}
|
||||
}
|
||||
// swiftlint:enable file_length
|
||||
|
|
|
@ -18,7 +18,10 @@ final public class CollectionItemsViewModel: ObservableObject {
|
|||
private let expandAllSubject: CurrentValueSubject<ExpandAllState, Never>
|
||||
private var maintainScrollPosition: CollectionItem?
|
||||
private var topVisibleIndexPath = IndexPath(item: 0, section: 0)
|
||||
private let lastReadId = CurrentValueSubject<String?, Never>(nil)
|
||||
private var lastSelectedLoadMore: LoadMore?
|
||||
private var hasRequestedUsingMarker = false
|
||||
private var hasRememberedPosition = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
public init(collectionService: CollectionService, identification: Identification) {
|
||||
|
@ -38,6 +41,15 @@ final public class CollectionItemsViewModel: ObservableObject {
|
|||
collectionService.nextPageMaxId
|
||||
.sink { [weak self] in self?.nextPageMaxId = $0 }
|
||||
.store(in: &cancellables)
|
||||
|
||||
if let markerTimeline = collectionService.markerTimeline {
|
||||
lastReadId.compactMap { $0 }
|
||||
.removeDuplicates()
|
||||
.debounce(for: 0.5, scheduler: DispatchQueue.global())
|
||||
.flatMap { identification.service.setLastReadId($0, forMarker: markerTimeline) }
|
||||
.sink { _ in } receiveValue: { _ in }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,8 +74,32 @@ extension CollectionItemsViewModel: CollectionViewModel {
|
|||
|
||||
public var events: AnyPublisher<CollectionItemEvent, Never> { eventsSubject.eraseToAnyPublisher() }
|
||||
|
||||
public var shouldAdjustContentInset: Bool { collectionService is ContextService }
|
||||
|
||||
public func request(maxId: String? = nil, minId: String? = nil) {
|
||||
collectionService.request(maxId: maxId, minId: minId)
|
||||
let publisher: AnyPublisher<Never, Error>
|
||||
|
||||
if let markerTimeline = collectionService.markerTimeline,
|
||||
identification.appPreferences.positionBehavior(markerTimeline: markerTimeline) == .syncPosition,
|
||||
!hasRequestedUsingMarker {
|
||||
publisher = identification.service.getMarker(markerTimeline)
|
||||
.flatMap { [weak self] in
|
||||
self?.collectionService.request(maxId: $0.lastReadId, minId: nil) ?? Empty().eraseToAnyPublisher()
|
||||
}
|
||||
.catch { [weak self] _ in
|
||||
self?.collectionService.request(maxId: nil, minId: nil) ?? Empty().eraseToAnyPublisher()
|
||||
}
|
||||
.collect()
|
||||
.flatMap { [weak self] _ in
|
||||
self?.collectionService.request(maxId: nil, minId: nil) ?? Empty().eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
self.hasRequestedUsingMarker = true
|
||||
} else {
|
||||
publisher = collectionService.request(maxId: maxId, minId: minId)
|
||||
}
|
||||
|
||||
publisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.handleEvents(
|
||||
|
@ -95,6 +131,15 @@ extension CollectionItemsViewModel: CollectionViewModel {
|
|||
|
||||
public func viewedAtTop(indexPath: IndexPath) {
|
||||
topVisibleIndexPath = indexPath
|
||||
|
||||
if items.value.count > indexPath.section, items.value[indexPath.section].count > indexPath.item {
|
||||
switch items.value[indexPath.section][indexPath.item] {
|
||||
case let .status(status, _):
|
||||
lastReadId.send(status.id)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func canSelect(indexPath: IndexPath) -> Bool {
|
||||
|
@ -196,9 +241,27 @@ private extension CollectionItemsViewModel {
|
|||
viewModelCache = viewModelCache.filter { itemsSet.contains($0.key) }
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
||||
func itemForScrollPositionMaintenance(newItems: [[CollectionItem]]) -> CollectionItem? {
|
||||
let flatNewItems = newItems.reduce([], +)
|
||||
|
||||
if let markerTimeline = collectionService.markerTimeline,
|
||||
identification.appPreferences.positionBehavior(markerTimeline: markerTimeline) == .rememberPosition,
|
||||
let localLastReadId = identification.service.getLocalLastReadId(markerTimeline),
|
||||
!hasRememberedPosition,
|
||||
let lastReadItem = flatNewItems.first(where: {
|
||||
switch $0 {
|
||||
case let .status(status, _):
|
||||
return status.id == localLastReadId
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}) {
|
||||
hasRememberedPosition = true
|
||||
|
||||
return lastReadItem
|
||||
}
|
||||
|
||||
if collectionService is ContextService,
|
||||
items.value.isEmpty || items.value.map(\.count) == [0, 1, 0],
|
||||
let contextParent = flatNewItems.first(where: {
|
||||
|
|
|
@ -10,6 +10,7 @@ public protocol CollectionViewModel {
|
|||
var alertItems: AnyPublisher<AlertItem, Never> { get }
|
||||
var loading: AnyPublisher<Bool, Never> { get }
|
||||
var events: AnyPublisher<CollectionItemEvent, Never> { get }
|
||||
var shouldAdjustContentInset: Bool { get }
|
||||
var nextPageMaxId: String? { get }
|
||||
func request(maxId: String?, minId: String?)
|
||||
func viewedAtTop(indexPath: IndexPath)
|
||||
|
|
|
@ -81,6 +81,10 @@ extension ProfileViewModel: CollectionViewModel {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public var shouldAdjustContentInset: Bool {
|
||||
collectionViewModel.value.shouldAdjustContentInset
|
||||
}
|
||||
|
||||
public var nextPageMaxId: String? {
|
||||
collectionViewModel.value.nextPageMaxId
|
||||
}
|
||||
|
|
|
@ -26,6 +26,8 @@ struct PreferencesView: View {
|
|||
NavigationLink("preferences.media",
|
||||
destination: MediaPreferencesView(
|
||||
viewModel: .init(identification: identification)))
|
||||
NavigationLink("preferences.startup-and-syncing",
|
||||
destination: StartupAndSyncingPreferencesView())
|
||||
}
|
||||
}
|
||||
.navigationTitle("preferences")
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import SwiftUI
|
||||
import ViewModels
|
||||
|
||||
struct StartupAndSyncingPreferencesView: View {
|
||||
@EnvironmentObject var identification: Identification
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("preferences.startup-and-syncing.home-timeline")) {
|
||||
Picker("preferences.startup-and-syncing.position-on-startup",
|
||||
selection: $identification.appPreferences.homeTimelineBehavior) {
|
||||
ForEach(AppPreferences.PositionBehavior.allCases) { option in
|
||||
Text(option.localizedStringKey).tag(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("preferences.startup-and-syncing.notifications-tab")) {
|
||||
Picker("preferences.startup-and-syncing.position-on-startup",
|
||||
selection: $identification.appPreferences.notificationsTabBehavior) {
|
||||
ForEach(AppPreferences.PositionBehavior.allCases) { option in
|
||||
Text(option.localizedStringKey).tag(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppPreferences.PositionBehavior {
|
||||
var localizedStringKey: LocalizedStringKey {
|
||||
switch self {
|
||||
case .rememberPosition:
|
||||
return "preferences.startup-and-syncing.remember-position"
|
||||
case .syncPosition:
|
||||
return "preferences.startup-and-syncing.sync-position"
|
||||
case .newest:
|
||||
return "preferences.startup-and-syncing.newest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import PreviewViewModels
|
||||
|
||||
struct StartupAndSyncingPreferencesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
StartupAndSyncingPreferencesView()
|
||||
.environmentObject(Identification.preview)
|
||||
}
|
||||
}
|
||||
#endif
|
Loading…
Reference in New Issue