diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index 588ae40..d5ab366 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -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) diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 6b103a2..54561da 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -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 { + 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 diff --git a/DB/Sources/DB/Content/LastReadIdRecord.swift b/DB/Sources/DB/Content/LastReadIdRecord.swift new file mode 100644 index 0000000..c55e2c6 --- /dev/null +++ b/DB/Sources/DB/Content/LastReadIdRecord.swift @@ -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) + } +} diff --git a/HTTP/Sources/HTTP/Target.swift b/HTTP/Sources/HTTP/Target.swift index efe943c..eecd25d 100644 --- a/HTTP/Sources/HTTP/Target.swift +++ b/HTTP/Sources/HTTP/Target.swift @@ -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 diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index c00498b..7dfe07c 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -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"; diff --git a/Mastodon/Sources/Mastodon/Entities/Marker.swift b/Mastodon/Sources/Mastodon/Entities/Marker.swift new file mode 100644 index 0000000..034c2ed --- /dev/null +++ b/Mastodon/Sources/Mastodon/Entities/Marker.swift @@ -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 + } +} diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoint.swift index ba0b5b5..baaa55e 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoint.swift @@ -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 } diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/MarkersEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/MarkersEndpoint.swift new file mode 100644 index 0000000..095bf97 --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/MarkersEndpoint.swift @@ -0,0 +1,45 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import HTTP +import Mastodon + +public enum MarkersEndpoint { + case get(Set) + 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 + } + } +} diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/Paged.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/Paged.swift index a6adb5c..b38d2f3 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/Paged.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/Paged.swift @@ -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 diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift index 1ba3a17..3d78fe2 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/ResultsEndpoint.swift @@ -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 diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift index 225e549..73db18a 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift @@ -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 [] } } diff --git a/MastodonAPI/Sources/MastodonAPI/MastodonAPITarget.swift b/MastodonAPI/Sources/MastodonAPI/MastodonAPITarget.swift index 8c1f719..98c4cc2 100644 --- a/MastodonAPI/Sources/MastodonAPI/MastodonAPITarget.swift +++ b/MastodonAPI/Sources/MastodonAPI/MastodonAPITarget.swift @@ -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 } diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index e3edaf3..0791815 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -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 = ""; }; D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; D03B1B29253818F3008F964B /* MediaPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreferencesView.swift; sourceTree = ""; }; + D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAndSyncingPreferencesView.swift; sourceTree = ""; }; 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 = ""; }; D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift index 4e4e2ff..11053ec 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/CollectionService.swift @@ -8,6 +8,7 @@ public protocol CollectionService { var nextPageMaxId: AnyPublisher { get } var title: AnyPublisher { get } var navigationService: NavigationService { get } + var markerTimeline: Marker.Timeline? { get } func request(maxId: String?, minId: String?) -> AnyPublisher } @@ -15,4 +16,6 @@ extension CollectionService { public var nextPageMaxId: AnyPublisher { Empty().eraseToAnyPublisher() } public var title: AnyPublisher { Empty().eraseToAnyPublisher() } + + public var markerTimeline: Marker.Timeline? { nil } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index bea9a92..a5f87aa 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -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 { + 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 { + 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 { identityDatabase.identityPublisher(id: id, immediate: immediate) } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift index 3b576d8..fcc7e40 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift @@ -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 } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift index e1cc3bb..aa948f0 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift @@ -15,15 +15,27 @@ public struct TimelineService { private let timeline: Timeline private let mastodonAPIClient: MastodonAPIClient private let contentDatabase: ContentDatabase - private let nextPageMaxIdSubject = PassthroughSubject() + private let nextPageMaxIdSubject: CurrentValueSubject init(timeline: Timeline, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { self.timeline = timeline self.mastodonAPIClient = mastodonAPIClient self.contentDatabase = contentDatabase + + let nextPageMaxIdSubject = CurrentValueSubject(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 { 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) }) diff --git a/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift b/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift index 68fbd59..bfb9ca3 100644 --- a/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift +++ b/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift @@ -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(index: Item) -> T? { diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 4031fa3..dca5466 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -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 diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index 92f2151..bcdaa32 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -18,7 +18,10 @@ final public class CollectionItemsViewModel: ObservableObject { private let expandAllSubject: CurrentValueSubject private var maintainScrollPosition: CollectionItem? private var topVisibleIndexPath = IndexPath(item: 0, section: 0) + private let lastReadId = CurrentValueSubject(nil) private var lastSelectedLoadMore: LoadMore? + private var hasRequestedUsingMarker = false + private var hasRememberedPosition = false private var cancellables = Set() 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 { 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 + + 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: { diff --git a/ViewModels/Sources/ViewModels/CollectionViewModel.swift b/ViewModels/Sources/ViewModels/CollectionViewModel.swift index 4ce86b0..f47adbc 100644 --- a/ViewModels/Sources/ViewModels/CollectionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionViewModel.swift @@ -10,6 +10,7 @@ public protocol CollectionViewModel { var alertItems: AnyPublisher { get } var loading: AnyPublisher { get } var events: AnyPublisher { get } + var shouldAdjustContentInset: Bool { get } var nextPageMaxId: String? { get } func request(maxId: String?, minId: String?) func viewedAtTop(indexPath: IndexPath) diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index b9597c1..14d8180 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -81,6 +81,10 @@ extension ProfileViewModel: CollectionViewModel { .eraseToAnyPublisher() } + public var shouldAdjustContentInset: Bool { + collectionViewModel.value.shouldAdjustContentInset + } + public var nextPageMaxId: String? { collectionViewModel.value.nextPageMaxId } diff --git a/Views/PreferencesView.swift b/Views/PreferencesView.swift index 4c44d3a..31db4c0 100644 --- a/Views/PreferencesView.swift +++ b/Views/PreferencesView.swift @@ -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") diff --git a/Views/StartupAndSyncingPreferencesView.swift b/Views/StartupAndSyncingPreferencesView.swift new file mode 100644 index 0000000..4cec53b --- /dev/null +++ b/Views/StartupAndSyncingPreferencesView.swift @@ -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