Startup and syncing

This commit is contained in:
Justin Mazzocchi 2020-10-26 20:01:12 -07:00
parent c7e4186749
commit ed82cffd91
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
24 changed files with 427 additions and 43 deletions

View file

@ -105,6 +105,11 @@ extension ContentDatabase {
t.column("wholeWord", .boolean).notNull() 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 try db.create(table: "statusAncestorJoin") { t in
t.column("parentId", .text).indexed().notNull() t.column("parentId", .text).indexed().notNull()
.references("statusRecord", onDelete: .cascade) .references("statusRecord", onDelete: .cascade)

View file

@ -7,12 +7,17 @@ import Keychain
import Mastodon import Mastodon
import Secrets import Secrets
// swiftlint:disable file_length
public struct ContentDatabase { public struct ContentDatabase {
public let activeFiltersPublisher: AnyPublisher<[Filter], Error> public let activeFiltersPublisher: AnyPublisher<[Filter], Error>
private let databaseWriter: DatabaseWriter 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 { if inMemory {
databaseWriter = DatabaseQueue() databaseWriter = DatabaseQueue()
} else { } else {
@ -27,7 +32,10 @@ public struct ContentDatabase {
} }
try Self.migrator.migrate(databaseWriter) try Self.migrator.migrate(databaseWriter)
try Self.clean(databaseWriter) try Self.clean(
databaseWriter,
useHomeTimelineLastReadId: useHomeTimelineLastReadId,
useNotificationsLastReadId: useNotificationsLastReadId)
activeFiltersPublisher = ValueObservation.tracking { activeFiltersPublisher = ValueObservation.tracking {
try Filter.filter(Filter.Columns.expiresAt == nil || Filter.Columns.expiresAt > Date()).fetchAll($0) try Filter.filter(Filter.Columns.expiresAt == nil || Filter.Columns.expiresAt > Date()).fetchAll($0)
@ -278,6 +286,12 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .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> { func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> {
ValueObservation.tracking( ValueObservation.tracking(
TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne)
@ -331,19 +345,64 @@ public extension ContentDatabase {
.publisher(in: databaseWriter) .publisher(in: databaseWriter)
.eraseToAnyPublisher() .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 { private extension ContentDatabase {
static let cleanAfterLastReadIdCount = 40
static func fileURL(id: Identity.Id) throws -> URL { static func fileURL(id: Identity.Id) throws -> URL {
try FileManager.default.databaseDirectoryURL(name: id.uuidString) 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 databaseWriter.write {
try TimelineRecord.deleteAll($0) if useHomeTimelineLastReadId {
try StatusRecord.deleteAll($0) try TimelineRecord.filter(TimelineRecord.Columns.id != Timeline.home.id).deleteAll($0)
try AccountRecord.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) try AccountList.deleteAll($0)
} }
} }
} }
// swiftlint:enable file_length

View file

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

View file

@ -6,7 +6,7 @@ public protocol Target {
var baseURL: URL { get } var baseURL: URL { get }
var pathComponents: [String] { get } var pathComponents: [String] { get }
var method: HTTPMethod { get } var method: HTTPMethod { get }
var queryParameters: [String: String]? { get } var queryParameters: [URLQueryItem] { get }
var jsonBody: [String: Any]? { get } var jsonBody: [String: Any]? { get }
var headers: [String: String]? { get } var headers: [String: String]? { get }
} }
@ -19,9 +19,8 @@ public extension Target {
url.appendPathComponent(pathComponent) url.appendPathComponent(pathComponent)
} }
if var components = URLComponents(url: url, resolvingAgainstBaseURL: true), if var components = URLComponents(url: url, resolvingAgainstBaseURL: true), !queryParameters.isEmpty {
let queryItems = queryParameters?.map(URLQueryItem.init(name:value:)) { components.queryItems = queryParameters
components.queryItems = queryItems
if let queryComponentURL = components.url { if let queryComponentURL = components.url {
url = queryComponentURL url = queryComponentURL

View file

@ -67,6 +67,13 @@
"preferences.notification-types.reblog" = "Reblog"; "preferences.notification-types.reblog" = "Reblog";
"preferences.notification-types.mention" = "Mention"; "preferences.notification-types.mention" = "Mention";
"preferences.notification-types.poll" = "Poll"; "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.active" = "Active";
"filters.expired" = "Expired"; "filters.expired" = "Expired";
"filter.add-new" = "Add New Filter"; "filter.add-new" = "Add New Filter";

View file

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

View file

@ -9,7 +9,7 @@ public protocol Endpoint {
var context: [String] { get } var context: [String] { get }
var pathComponentsInContext: [String] { get } var pathComponentsInContext: [String] { get }
var method: HTTPMethod { get } var method: HTTPMethod { get }
var queryParameters: [String: String]? { get } var queryParameters: [URLQueryItem] { get }
var jsonBody: [String: Any]? { get } var jsonBody: [String: Any]? { get }
var headers: [String: String]? { get } var headers: [String: String]? { get }
} }
@ -29,7 +29,7 @@ public extension Endpoint {
context + pathComponentsInContext context + pathComponentsInContext
} }
var queryParameters: [String: String]? { nil } var queryParameters: [URLQueryItem] { [] }
var jsonBody: [String: Any]? { nil } var jsonBody: [String: Any]? { nil }

View file

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

View file

@ -31,15 +31,23 @@ extension Paged: Endpoint {
public var method: HTTPMethod { endpoint.method } public var method: HTTPMethod { endpoint.method }
public var queryParameters: [String: String]? { public var queryParameters: [URLQueryItem] {
var queryParameters = endpoint.queryParameters ?? [String: String]() var queryParameters = endpoint.queryParameters
queryParameters["max_id"] = maxId if let maxId = maxId {
queryParameters["min_id"] = minId queryParameters.append(.init(name: "max_id", value: maxId))
queryParameters["since_id"] = sinceId }
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 { if let limit = limit {
queryParameters["limit"] = String(limit) queryParameters.append(.init(name: "limit", value: String(limit)))
} }
return queryParameters return queryParameters

View file

@ -32,13 +32,13 @@ extension ResultsEndpoint: Endpoint {
} }
} }
public var queryParameters: [String: String]? { public var queryParameters: [URLQueryItem] {
switch self { switch self {
case let .search(query, resolve): case let .search(query, resolve):
var params = ["q": query] var params = [URLQueryItem(name: "q", value: query)]
if resolve { if resolve {
params["resolve"] = String(true) params.append(.init(name: "resolve", value: "true"))
} }
return params return params

View file

@ -39,16 +39,16 @@ extension StatusesEndpoint: Endpoint {
} }
} }
public var queryParameters: [String: String]? { public var queryParameters: [URLQueryItem] {
switch self { switch self {
case let .timelinesPublic(local): case let .timelinesPublic(local):
return ["local": String(local)] return [URLQueryItem(name: "local", value: String(local))]
case let .accountsStatuses(_, excludeReplies, onlyMedia, pinned): case let .accountsStatuses(_, excludeReplies, onlyMedia, pinned):
return ["exclude_replies": String(excludeReplies), return [URLQueryItem(name: "exclude_replies", value: String(excludeReplies)),
"only_media": String(onlyMedia), URLQueryItem(name: "only_media", value: String(onlyMedia)),
"pinned": String(pinned)] URLQueryItem(name: "pinned", value: String(pinned))]
default: default:
return nil return []
} }
} }

View file

@ -22,7 +22,7 @@ extension MastodonAPITarget: DecodableTarget {
public var method: HTTPMethod { endpoint.method } 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 } public var jsonBody: [String: Any]? { endpoint.jsonBody }

View file

@ -15,6 +15,7 @@
D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */; }; D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */; };
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; }; D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; };
D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03B1B29253818F3008F964B /* MediaPreferencesView.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 */; }; D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; };
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; }; D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; };
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = "<group>"; };
@ -348,6 +350,7 @@
D0C7D42724F76169001EBDBB /* RootView.swift */, D0C7D42724F76169001EBDBB /* RootView.swift */,
D02E1F94250B13210071AD56 /* SafariView.swift */, D02E1F94250B13210071AD56 /* SafariView.swift */,
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */, D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */,
D0625E55250F086B00502611 /* Status */, D0625E55250F086B00502611 /* Status */,
D0C7D42524F76169001EBDBB /* TableView.swift */, D0C7D42524F76169001EBDBB /* TableView.swift */,
D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */, D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */,
@ -632,6 +635,7 @@
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */, D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */,
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */, D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */,
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */, D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */,
D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */,
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */, D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */,
D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */, D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */,
D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */, D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */,

View file

@ -8,6 +8,7 @@ public protocol CollectionService {
var nextPageMaxId: AnyPublisher<String, Never> { get } var nextPageMaxId: AnyPublisher<String, Never> { get }
var title: AnyPublisher<String, Never> { get } var title: AnyPublisher<String, Never> { get }
var navigationService: NavigationService { get } var navigationService: NavigationService { get }
var markerTimeline: Marker.Timeline? { get }
func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> 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 nextPageMaxId: AnyPublisher<String, Never> { Empty().eraseToAnyPublisher() }
public var title: AnyPublisher<String, Never> { Empty().eraseToAnyPublisher() } public var title: AnyPublisher<String, Never> { Empty().eraseToAnyPublisher() }
public var markerTimeline: Marker.Timeline? { nil }
} }

View file

@ -27,9 +27,14 @@ public struct IdentityService {
instanceURL: try secrets.getInstanceURL()) instanceURL: try secrets.getInstanceURL())
mastodonAPIClient.accessToken = try? secrets.getAccessToken() mastodonAPIClient.accessToken = try? secrets.getAccessToken()
contentDatabase = try ContentDatabase(id: id, let appPreferences = AppPreferences(environment: environment)
inMemory: environment.inMemoryContent,
keychain: environment.keychain) 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() .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> { func identityPublisher(immediate: Bool) -> AnyPublisher<Identity, Error> {
identityDatabase.identityPublisher(id: id, immediate: immediate) identityDatabase.identityPublisher(id: id, immediate: immediate)
} }

View file

@ -73,7 +73,7 @@ private struct UpdatedFilterTarget: DecodableTarget {
let baseURL = URL(string: "https://filter.metabolist.com")! let baseURL = URL(string: "https://filter.metabolist.com")!
let pathComponents = ["filter"] let pathComponents = ["filter"]
let method = HTTPMethod.get let method = HTTPMethod.get
let queryParameters: [String: String]? = nil let queryParameters: [URLQueryItem] = []
let jsonBody: [String: Any]? = nil let jsonBody: [String: Any]? = nil
let headers: [String: String]? = nil let headers: [String: String]? = nil
} }

View file

@ -15,15 +15,27 @@ public struct TimelineService {
private let timeline: Timeline private let timeline: Timeline
private let mastodonAPIClient: MastodonAPIClient private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase private let contentDatabase: ContentDatabase
private let nextPageMaxIdSubject = PassthroughSubject<String, Never>() private let nextPageMaxIdSubject: CurrentValueSubject<String, Never>
init(timeline: Timeline, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { init(timeline: Timeline, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.timeline = timeline self.timeline = timeline
self.mastodonAPIClient = mastodonAPIClient self.mastodonAPIClient = mastodonAPIClient
self.contentDatabase = contentDatabase self.contentDatabase = contentDatabase
let nextPageMaxIdSubject = CurrentValueSubject<String, Never>(String(Int.max))
self.nextPageMaxIdSubject = nextPageMaxIdSubject
sections = contentDatabase.timelinePublisher(timeline) 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) navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher() nextPageMaxId = nextPageMaxIdSubject.dropFirst().eraseToAnyPublisher()
if case let .tag(tag) = timeline { if case let .tag(tag) = timeline {
title = Just("#".appending(tag)).eraseToAnyPublisher() title = Just("#".appending(tag)).eraseToAnyPublisher()
@ -34,10 +46,19 @@ public struct TimelineService {
} }
extension TimelineService: CollectionService { 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> { public func request(maxId: String?, minId: String?) -> AnyPublisher<Never, Error> {
mastodonAPIClient.pagedRequest(timeline.endpoint, maxId: maxId, minId: minId) mastodonAPIClient.pagedRequest(timeline.endpoint, maxId: maxId, minId: minId)
.handleEvents(receiveOutput: { .handleEvents(receiveOutput: {
guard let maxId = $0.info.maxId else { return } guard let maxId = $0.info.maxId, maxId < nextPageMaxIdSubject.value else { return }
nextPageMaxIdSubject.send(maxId) nextPageMaxIdSubject.send(maxId)
}) })

View file

@ -2,6 +2,7 @@
import CodableBloomFilter import CodableBloomFilter
import Foundation import Foundation
import Mastodon
public struct AppPreferences { public struct AppPreferences {
private let userDefaults: UserDefaults private let userDefaults: UserDefaults
@ -30,6 +31,14 @@ public extension AppPreferences {
public var id: String { rawValue } public var id: String { rawValue }
} }
enum PositionBehavior: String, CaseIterable, Identifiable {
case rememberPosition
case syncPosition
case newest
public var id: String { rawValue }
}
var useSystemReduceMotionForMedia: Bool { var useSystemReduceMotionForMedia: Bool {
get { self[.useSystemReduceMotionForMedia] ?? true } get { self[.useSystemReduceMotionForMedia] ?? true }
set { self[.useSystemReduceMotionForMedia] = newValue } set { self[.useSystemReduceMotionForMedia] = newValue }
@ -76,9 +85,42 @@ public extension AppPreferences {
set { self[.autoplayVideos] = newValue.rawValue } 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 { var shouldReduceMotion: Bool {
systemReduceMotion() && useSystemReduceMotionForMedia systemReduceMotion() && useSystemReduceMotionForMedia
} }
func positionBehavior(markerTimeline: Marker.Timeline) -> PositionBehavior {
switch markerTimeline {
case .home:
return homeTimelineBehavior
case .notifications:
return notificationsTabBehavior
}
}
} }
extension AppPreferences { extension AppPreferences {
@ -103,6 +145,8 @@ private extension AppPreferences {
case animateHeaders case animateHeaders
case autoplayGIFs case autoplayGIFs
case autoplayVideos case autoplayVideos
case homeTimelineBehavior
case notificationsTabBehavior
} }
subscript<T>(index: Item) -> T? { subscript<T>(index: Item) -> T? {

View file

@ -6,6 +6,7 @@ import SafariServices
import SwiftUI import SwiftUI
import ViewModels import ViewModels
// swiftlint:disable file_length
class TableViewController: UITableViewController { class TableViewController: UITableViewController {
var transitionViewTag = -1 var transitionViewTag = -1
@ -272,13 +273,16 @@ private extension TableViewController {
if if
let item = update.maintainScrollPosition, let item = update.maintainScrollPosition,
let indexPath = self.dataSource.indexPath(for: item) { let indexPath = self.dataSource.indexPath(for: item) {
self.tableView.contentInset.bottom = max( if self.viewModel.shouldAdjustContentInset {
0, self.tableView.contentInset.bottom = max(
self.tableView.frame.height 0,
- self.tableView.contentSize.height self.tableView.frame.height
- self.tableView.safeAreaInsets.top - self.tableView.contentSize.height
- self.tableView.safeAreaInsets.bottom) - self.tableView.safeAreaInsets.top
+ self.tableView.rectForRow(at: indexPath).minY - self.tableView.safeAreaInsets.bottom)
+ self.tableView.rectForRow(at: indexPath).minY
}
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
if let offsetFromNavigationBar = offsetFromNavigationBar { if let offsetFromNavigationBar = offsetFromNavigationBar {
@ -399,3 +403,4 @@ private extension TableViewController {
} }
} }
} }
// swiftlint:enable file_length

View file

@ -18,7 +18,10 @@ final public class CollectionItemsViewModel: ObservableObject {
private let expandAllSubject: CurrentValueSubject<ExpandAllState, Never> private let expandAllSubject: CurrentValueSubject<ExpandAllState, Never>
private var maintainScrollPosition: CollectionItem? private var maintainScrollPosition: CollectionItem?
private var topVisibleIndexPath = IndexPath(item: 0, section: 0) private var topVisibleIndexPath = IndexPath(item: 0, section: 0)
private let lastReadId = CurrentValueSubject<String?, Never>(nil)
private var lastSelectedLoadMore: LoadMore? private var lastSelectedLoadMore: LoadMore?
private var hasRequestedUsingMarker = false
private var hasRememberedPosition = false
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
public init(collectionService: CollectionService, identification: Identification) { public init(collectionService: CollectionService, identification: Identification) {
@ -38,6 +41,15 @@ final public class CollectionItemsViewModel: ObservableObject {
collectionService.nextPageMaxId collectionService.nextPageMaxId
.sink { [weak self] in self?.nextPageMaxId = $0 } .sink { [weak self] in self?.nextPageMaxId = $0 }
.store(in: &cancellables) .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 events: AnyPublisher<CollectionItemEvent, Never> { eventsSubject.eraseToAnyPublisher() }
public var shouldAdjustContentInset: Bool { collectionService is ContextService }
public func request(maxId: String? = nil, minId: String? = nil) { 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) .receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.handleEvents( .handleEvents(
@ -95,6 +131,15 @@ extension CollectionItemsViewModel: CollectionViewModel {
public func viewedAtTop(indexPath: IndexPath) { public func viewedAtTop(indexPath: IndexPath) {
topVisibleIndexPath = 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 { public func canSelect(indexPath: IndexPath) -> Bool {
@ -196,9 +241,27 @@ private extension CollectionItemsViewModel {
viewModelCache = viewModelCache.filter { itemsSet.contains($0.key) } viewModelCache = viewModelCache.filter { itemsSet.contains($0.key) }
} }
// swiftlint:disable:next cyclomatic_complexity function_body_length
func itemForScrollPositionMaintenance(newItems: [[CollectionItem]]) -> CollectionItem? { func itemForScrollPositionMaintenance(newItems: [[CollectionItem]]) -> CollectionItem? {
let flatNewItems = newItems.reduce([], +) 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, if collectionService is ContextService,
items.value.isEmpty || items.value.map(\.count) == [0, 1, 0], items.value.isEmpty || items.value.map(\.count) == [0, 1, 0],
let contextParent = flatNewItems.first(where: { let contextParent = flatNewItems.first(where: {

View file

@ -10,6 +10,7 @@ public protocol CollectionViewModel {
var alertItems: AnyPublisher<AlertItem, Never> { get } var alertItems: AnyPublisher<AlertItem, Never> { get }
var loading: AnyPublisher<Bool, Never> { get } var loading: AnyPublisher<Bool, Never> { get }
var events: AnyPublisher<CollectionItemEvent, Never> { get } var events: AnyPublisher<CollectionItemEvent, Never> { get }
var shouldAdjustContentInset: Bool { get }
var nextPageMaxId: String? { get } var nextPageMaxId: String? { get }
func request(maxId: String?, minId: String?) func request(maxId: String?, minId: String?)
func viewedAtTop(indexPath: IndexPath) func viewedAtTop(indexPath: IndexPath)

View file

@ -81,6 +81,10 @@ extension ProfileViewModel: CollectionViewModel {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
public var shouldAdjustContentInset: Bool {
collectionViewModel.value.shouldAdjustContentInset
}
public var nextPageMaxId: String? { public var nextPageMaxId: String? {
collectionViewModel.value.nextPageMaxId collectionViewModel.value.nextPageMaxId
} }

View file

@ -26,6 +26,8 @@ struct PreferencesView: View {
NavigationLink("preferences.media", NavigationLink("preferences.media",
destination: MediaPreferencesView( destination: MediaPreferencesView(
viewModel: .init(identification: identification))) viewModel: .init(identification: identification)))
NavigationLink("preferences.startup-and-syncing",
destination: StartupAndSyncingPreferencesView())
} }
} }
.navigationTitle("preferences") .navigationTitle("preferences")

View file

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