From 8d1f94d449b92a96d6ba53187132ce763225c888 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sat, 3 Oct 2020 02:19:05 -0700 Subject: [PATCH] DB refactoring WIP --- .../DB/Content/AccountPinnedStatusJoin.swift | 4 +- .../Content/ContentDatabase+Migration.swift | 15 ++- DB/Sources/DB/Content/ContentDatabase.swift | 111 ++++-------------- DB/Sources/DB/Content/ContextItemsInfo.swift | 42 +++++++ DB/Sources/DB/Content/LoadMoreRecord.swift | 27 +++++ .../DB/Content/StatusAncestorJoin.swift | 20 ++++ DB/Sources/DB/Content/StatusContextJoin.swift | 27 ----- .../DB/Content/StatusDescendantJoin.swift | 20 ++++ DB/Sources/DB/Content/StatusRecord.swift | 18 ++- DB/Sources/DB/Content/TimelineItemsInfo.swift | 64 ++++++++++ DB/Sources/DB/Content/TimelineRecord.swift | 5 +- DB/Sources/DB/Entities/LoadMore.swift | 22 +--- .../DB/Extensions/Filter+Extensions.swift | 4 +- .../Sources/Mastodon/Entities/Filter.swift | 8 +- .../ServiceLayer/Entities/Timeline.swift | 39 ++++++ .../Services/LoadMoreService.swift | 28 +++++ .../Services/StatusListService.swift | 38 ------ .../ViewModels/LoadMoreViewModel.swift | 4 +- 18 files changed, 307 insertions(+), 189 deletions(-) create mode 100644 DB/Sources/DB/Content/ContextItemsInfo.swift create mode 100644 DB/Sources/DB/Content/LoadMoreRecord.swift create mode 100644 DB/Sources/DB/Content/StatusAncestorJoin.swift delete mode 100644 DB/Sources/DB/Content/StatusContextJoin.swift create mode 100644 DB/Sources/DB/Content/StatusDescendantJoin.swift create mode 100644 DB/Sources/DB/Content/TimelineItemsInfo.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Services/LoadMoreService.swift diff --git a/DB/Sources/DB/Content/AccountPinnedStatusJoin.swift b/DB/Sources/DB/Content/AccountPinnedStatusJoin.swift index 8583173..123e4b1 100644 --- a/DB/Sources/DB/Content/AccountPinnedStatusJoin.swift +++ b/DB/Sources/DB/Content/AccountPinnedStatusJoin.swift @@ -7,8 +7,6 @@ struct AccountPinnedStatusJoin: Codable, FetchableRecord, PersistableRecord { let accountId: String let statusId: String let index: Int - - static let status = belongsTo(StatusRecord.self) } extension AccountPinnedStatusJoin { @@ -17,4 +15,6 @@ extension AccountPinnedStatusJoin { static let statusId = Column(AccountPinnedStatusJoin.CodingKeys.statusId) static let index = Column(AccountPinnedStatusJoin.CodingKeys.index) } + + static let status = belongsTo(StatusRecord.self) } diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index 3bae8ea..eed40ed 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -71,7 +71,7 @@ extension ContentDatabase { t.column("profileCollection", .text) } - try db.create(table: "loadMore") { t in + try db.create(table: "loadMoreRecord") { t in t.column("timelineId").notNull().references("timelineRecord", onDelete: .cascade) t.column("afterStatusId", .text).notNull() @@ -96,12 +96,21 @@ extension ContentDatabase { t.column("wholeWord", .boolean).notNull() } - try db.create(table: "statusContextJoin") { t in + try db.create(table: "statusAncestorJoin") { t in + t.column("parentId", .text).indexed().notNull() + .references("statusRecord", onDelete: .cascade) + t.column("statusId", .text).indexed().notNull() + .references("statusRecord", onDelete: .cascade) + t.column("index", .integer).notNull() + + t.primaryKey(["parentId", "statusId"], onConflict: .replace) + } + + try db.create(table: "statusDescendantJoin") { t in t.column("parentId", .text).indexed().notNull() .references("statusRecord", onDelete: .cascade) t.column("statusId", .text).indexed().notNull() .references("statusRecord", onDelete: .cascade) - t.column("section", .text).indexed().notNull() t.column("index", .integer).notNull() t.primaryKey(["parentId", "statusId"], onConflict: .replace) diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index fc5a093..ece76ce 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -57,7 +57,7 @@ public extension ContentDatabase { if let maxIDPresent = maxIDPresent, let minIDInserted = statuses.map(\.id).min(), minIDInserted > maxIDPresent { - try LoadMore(timelineId: timeline.id, afterStatusId: minIDInserted).save($0) + try LoadMoreRecord(timelineId: timeline.id, afterStatusId: minIDInserted).save($0) } } .ignoreOutput() @@ -66,27 +66,25 @@ public extension ContentDatabase { func insert(context: Context, parentID: String) -> AnyPublisher { databaseWriter.writePublisher { - for status in context.ancestors + context.descendants { + for (index, status) in context.ancestors.enumerated() { try status.save($0) + try StatusAncestorJoin(parentId: parentID, statusId: status.id, index: index).save($0) } - for (section, statuses) in [(StatusContextJoin.Section.ancestors, context.ancestors), - (StatusContextJoin.Section.descendants, context.descendants)] { - for (index, status) in statuses.enumerated() { - try StatusContextJoin( - parentId: parentID, - statusId: status.id, - section: section, - index: index) - .save($0) - } - - try StatusContextJoin.filter( - StatusContextJoin.Columns.parentId == parentID - && StatusContextJoin.Columns.section == section.rawValue - && !statuses.map(\.id).contains(StatusContextJoin.Columns.statusId)) - .deleteAll($0) + for (index, status) in context.descendants.enumerated() { + try status.save($0) + try StatusDescendantJoin(parentId: parentID, statusId: status.id, index: index).save($0) } + + try StatusAncestorJoin.filter( + StatusAncestorJoin.Columns.parentId == parentID + && !context.ancestors.map(\.id).contains(StatusAncestorJoin.Columns.statusId)) + .deleteAll($0) + + try StatusDescendantJoin.filter( + StatusDescendantJoin.Columns.parentId == parentID + && !context.descendants.map(\.id).contains(StatusDescendantJoin.Columns.statusId)) + .deleteAll($0) } .ignoreOutput() .eraseToAnyPublisher() @@ -96,7 +94,6 @@ public extension ContentDatabase { databaseWriter.writePublisher { for (index, status) in pinnedStatuses.enumerated() { try status.save($0) - try AccountPinnedStatusJoin(accountId: accountID, statusId: status.id, index: index).save($0) } @@ -175,80 +172,24 @@ public extension ContentDatabase { .eraseToAnyPublisher() } - // Awkward maps explained: https://github.com/groue/GRDB.swift#valueobservation-performance - func observation(timeline: Timeline) -> AnyPublisher<[[Timeline.Item]], Error> { - ValueObservation.tracking { db -> ([StatusInfo], [StatusInfo]?, [LoadMore], [Filter]) in - let timelineRecord = TimelineRecord(timeline: timeline) - let statuses = try timelineRecord.statuses.fetchAll(db) - let loadMores = try timelineRecord.loadMores.fetchAll(db) - let filters = try Filter.active.fetchAll(db) - - if case let .profile(accountId, profileCollection) = timeline, profileCollection == .statuses { - let pinnedStatuses = try AccountRecord.filter(AccountRecord.Columns.id == accountId) - .fetchOne(db)?.pinnedStatuses.fetchAll(db) - - return (statuses, pinnedStatuses, loadMores, filters) - } else { - return (statuses, nil, loadMores, filters) - } - } - .map { statuses, pinnedStatuses, loadMores, filters -> [[Timeline.Item]] in - var timelineItems = statuses.filtered(filters: filters, context: timeline.filterContext) - .map { Timeline.Item.status(.init(status: .init(info: $0))) } - - for loadMore in loadMores { - guard let index = timelineItems.firstIndex(where: { - guard case let .status(configuration) = $0 else { return false } - - return loadMore.afterStatusId > configuration.status.id - }) else { continue } - - timelineItems.insert(.loadMore(loadMore), at: index) - } - - if let pinnedStatuses = pinnedStatuses { - return [pinnedStatuses.filtered(filters: filters, context: timeline.filterContext) - .map { Timeline.Item.status(.init(status: .init(info: $0), pinned: true)) }, - timelineItems] - } else { - return [timelineItems] - } + ValueObservation.tracking { db -> (TimelineItemsInfo?, [Filter]) in + (try TimelineItemsInfo.request( + TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne(db), + try Filter.active.fetchAll(db)) } + .map { $0?.items(filters: $1) ?? [] } .removeDuplicates() .publisher(in: databaseWriter) .eraseToAnyPublisher() } func contextObservation(parentID: String) -> AnyPublisher<[[Timeline.Item]], Error> { - ValueObservation.tracking { db -> ([[StatusInfo]], [Filter]) in - guard let parent = try StatusInfo.request(StatusRecord.filter(StatusRecord.Columns.id == parentID)) - .fetchOne(db) else { - return ([], []) - } - - let ancestors = try parent.record.ancestors.fetchAll(db) - let descendants = try parent.record.descendants.fetchAll(db) - - return ([ancestors, [parent], descendants], try Filter.active.fetchAll(db)) - } - .map { statusSections, filters in - statusSections.map { section in - section.filtered(filters: filters, context: .thread) - .enumerated() - .map { index, statusInfo in - let isReplyInContext = index > 0 - && section[index - 1].record.id == statusInfo.record.inReplyToId - let hasReplyFollowing = section.count > index + 1 - && section[index + 1].record.inReplyToId == statusInfo.record.id - - return Timeline.Item.status( - .init(status: .init(info: statusInfo), - isReplyInContext: isReplyInContext, - hasReplyFollowing: hasReplyFollowing)) - } - } + ValueObservation.tracking { db -> (ContextItemsInfo?, [Filter]) in + (try ContextItemsInfo.request(StatusRecord.filter(StatusRecord.Columns.id == parentID)).fetchOne(db), + try Filter.active.fetchAll(db)) } + .map { $0?.items(filters: $1) ?? [] } .removeDuplicates() .publisher(in: databaseWriter) .eraseToAnyPublisher() @@ -259,8 +200,8 @@ public extension ContentDatabase { .order(TimelineRecord.Columns.listTitle.asc) .fetchAll) .removeDuplicates() - .map { $0.map(Timeline.init(record:)).compactMap { $0 } } .publisher(in: databaseWriter) + .tryMap { $0.map(Timeline.init(record:)).compactMap { $0 } } .eraseToAnyPublisher() } diff --git a/DB/Sources/DB/Content/ContextItemsInfo.swift b/DB/Sources/DB/Content/ContextItemsInfo.swift new file mode 100644 index 0000000..ee1705c --- /dev/null +++ b/DB/Sources/DB/Content/ContextItemsInfo.swift @@ -0,0 +1,42 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct ContextItemsInfo: Codable, Hashable, FetchableRecord { + let parent: StatusInfo + let ancestors: [StatusInfo] + let descendants: [StatusInfo] +} + +extension ContextItemsInfo { + static func addingIncludes(_ request: T) -> T where T.RowDecoder == StatusRecord { + StatusInfo.addingIncludes(request) + .including(all: StatusInfo.addingIncludes(StatusRecord.ancestors).forKey(CodingKeys.ancestors)) + .including(all: StatusInfo.addingIncludes(StatusRecord.descendants).forKey(CodingKeys.descendants)) + } + + static func request(_ request: QueryInterfaceRequest) -> QueryInterfaceRequest { + addingIncludes(request).asRequest(of: self) + } + + func items(filters: [Filter]) -> [[Timeline.Item]] { + let regularExpression = filters.regularExpression(context: .thread) + + return [ancestors, [parent], descendants].map { section in + section.filtered(regularExpression: regularExpression) + .enumerated() + .map { index, statusInfo in + let isReplyInContext = index > 0 + && section[index - 1].record.id == statusInfo.record.inReplyToId + let hasReplyFollowing = section.count > index + 1 + && section[index + 1].record.inReplyToId == statusInfo.record.id + + return .status(.init(status: .init(info: statusInfo), + isReplyInContext: isReplyInContext, + hasReplyFollowing: hasReplyFollowing)) + } + } + } +} diff --git a/DB/Sources/DB/Content/LoadMoreRecord.swift b/DB/Sources/DB/Content/LoadMoreRecord.swift new file mode 100644 index 0000000..38a4275 --- /dev/null +++ b/DB/Sources/DB/Content/LoadMoreRecord.swift @@ -0,0 +1,27 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct LoadMoreRecord: Codable, Hashable { + let timelineId: String + let afterStatusId: String +} + +extension LoadMoreRecord { + enum Columns { + static let timelineId = Column(LoadMoreRecord.CodingKeys.timelineId) + static let afterStatusId = Column(LoadMoreRecord.CodingKeys.afterStatusId) + } +} + +extension LoadMoreRecord: FetchableRecord, PersistableRecord { + static func databaseJSONDecoder(for column: String) -> JSONDecoder { + MastodonDecoder() + } + + static func databaseJSONEncoder(for column: String) -> JSONEncoder { + MastodonEncoder() + } +} diff --git a/DB/Sources/DB/Content/StatusAncestorJoin.swift b/DB/Sources/DB/Content/StatusAncestorJoin.swift new file mode 100644 index 0000000..c0cc8b2 --- /dev/null +++ b/DB/Sources/DB/Content/StatusAncestorJoin.swift @@ -0,0 +1,20 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct StatusAncestorJoin: Codable, FetchableRecord, PersistableRecord { + let parentId: String + let statusId: String + let index: Int + + static let status = belongsTo(StatusRecord.self, using: ForeignKey([Columns.statusId])) +} + +extension StatusAncestorJoin { + enum Columns { + static let parentId = Column(StatusAncestorJoin.CodingKeys.parentId) + static let statusId = Column(StatusAncestorJoin.CodingKeys.statusId) + static let index = Column(StatusAncestorJoin.CodingKeys.index) + } +} diff --git a/DB/Sources/DB/Content/StatusContextJoin.swift b/DB/Sources/DB/Content/StatusContextJoin.swift deleted file mode 100644 index b271f8d..0000000 --- a/DB/Sources/DB/Content/StatusContextJoin.swift +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import Foundation -import GRDB - -struct StatusContextJoin: Codable, FetchableRecord, PersistableRecord { - enum Section: String, Codable { - case ancestors - case descendants - } - - let parentId: String - let statusId: String - let section: Section - let index: Int - - static let status = belongsTo(StatusRecord.self, using: ForeignKey([Columns.statusId])) -} - -extension StatusContextJoin { - enum Columns { - static let parentId = Column(StatusContextJoin.CodingKeys.parentId) - static let statusId = Column(StatusContextJoin.CodingKeys.statusId) - static let section = Column(StatusContextJoin.CodingKeys.section) - static let index = Column(StatusContextJoin.CodingKeys.index) - } -} diff --git a/DB/Sources/DB/Content/StatusDescendantJoin.swift b/DB/Sources/DB/Content/StatusDescendantJoin.swift new file mode 100644 index 0000000..133bafa --- /dev/null +++ b/DB/Sources/DB/Content/StatusDescendantJoin.swift @@ -0,0 +1,20 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct StatusDescendantJoin: Codable, FetchableRecord, PersistableRecord { + let parentId: String + let statusId: String + let index: Int + + static let status = belongsTo(StatusRecord.self, using: ForeignKey([Columns.statusId])) +} + +extension StatusDescendantJoin { + enum Columns { + static let parentId = Column(StatusDescendantJoin.CodingKeys.parentId) + static let statusId = Column(StatusDescendantJoin.CodingKeys.statusId) + static let index = Column(StatusDescendantJoin.CodingKeys.index) + } +} diff --git a/DB/Sources/DB/Content/StatusRecord.swift b/DB/Sources/DB/Content/StatusRecord.swift index f3c861a..f6c9d61 100644 --- a/DB/Sources/DB/Content/StatusRecord.swift +++ b/DB/Sources/DB/Content/StatusRecord.swift @@ -93,21 +93,19 @@ extension StatusRecord { using: AccountRecord.moved) static let reblog = belongsTo(StatusRecord.self) static let ancestorJoins = hasMany( - StatusContextJoin.self, - using: ForeignKey([StatusContextJoin.Columns.parentId])) - .filter(StatusContextJoin.Columns.section == StatusContextJoin.Section.ancestors.rawValue) - .order(StatusContextJoin.Columns.index) + StatusAncestorJoin.self, + using: ForeignKey([StatusAncestorJoin.Columns.parentId])) + .order(StatusAncestorJoin.Columns.index) static let descendantJoins = hasMany( - StatusContextJoin.self, - using: ForeignKey([StatusContextJoin.Columns.parentId])) - .filter(StatusContextJoin.Columns.section == StatusContextJoin.Section.descendants.rawValue) - .order(StatusContextJoin.Columns.index) + StatusDescendantJoin.self, + using: ForeignKey([StatusDescendantJoin.Columns.parentId])) + .order(StatusDescendantJoin.Columns.index) static let ancestors = hasMany(StatusRecord.self, through: ancestorJoins, - using: StatusContextJoin.status) + using: StatusAncestorJoin.status) static let descendants = hasMany(StatusRecord.self, through: descendantJoins, - using: StatusContextJoin.status) + using: StatusDescendantJoin.status) var ancestors: QueryInterfaceRequest { StatusInfo.request(request(for: Self.ancestors)) diff --git a/DB/Sources/DB/Content/TimelineItemsInfo.swift b/DB/Sources/DB/Content/TimelineItemsInfo.swift new file mode 100644 index 0000000..46c1a75 --- /dev/null +++ b/DB/Sources/DB/Content/TimelineItemsInfo.swift @@ -0,0 +1,64 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct TimelineItemsInfo: Codable, Hashable, FetchableRecord { + let timelineRecord: TimelineRecord + let statusInfos: [StatusInfo] + let pinnedStatusesInfo: PinnedStatusesInfo? + let loadMoreRecords: [LoadMoreRecord] +} + +extension TimelineItemsInfo { + struct PinnedStatusesInfo: Codable, Hashable, FetchableRecord { + let accountRecord: AccountRecord + let pinnedStatusInfos: [StatusInfo] + } + + static func addingIncludes( _ request: T) -> T where T.RowDecoder == TimelineRecord { + request.including(all: StatusInfo.addingIncludes(TimelineRecord.statuses).forKey(CodingKeys.statusInfos)) + .including(all: TimelineRecord.loadMores.forKey(CodingKeys.loadMoreRecords)) + .including(optional: PinnedStatusesInfo.addingIncludes(TimelineRecord.account) + .forKey(CodingKeys.pinnedStatusesInfo)) + } + + static func request(_ request: QueryInterfaceRequest) -> QueryInterfaceRequest { + addingIncludes(request).asRequest(of: self) + } + + func items(filters: [Filter]) -> [[Timeline.Item]] { + let timeline = Timeline(record: timelineRecord)! + let filterRegularExpression = filters.regularExpression(context: timeline.filterContext) + var timelineItems = statusInfos.filtered(regularExpression: filterRegularExpression) + .map { Timeline.Item.status(.init(status: .init(info: $0))) } + + for loadMoreRecord in loadMoreRecords { + guard let index = timelineItems.firstIndex(where: { + guard case let .status(configuration) = $0 else { return false } + + return loadMoreRecord.afterStatusId > configuration.status.id + }) else { continue } + + timelineItems.insert( + .loadMore(LoadMore(timeline: timeline, afterStatusId: loadMoreRecord.afterStatusId)), + at: index) + } + + if let pinnedStatusInfos = pinnedStatusesInfo?.pinnedStatusInfos { + return [pinnedStatusInfos.filtered(regularExpression: filterRegularExpression) + .map { Timeline.Item.status(.init(status: .init(info: $0), pinned: true)) }, + timelineItems] + } else { + return [timelineItems] + } + } +} + +extension TimelineItemsInfo.PinnedStatusesInfo { + static func addingIncludes(_ request: T) -> T where T.RowDecoder == AccountRecord { + request.including(all: StatusInfo.addingIncludes(AccountRecord.pinnedStatuses) + .forKey(CodingKeys.pinnedStatusInfos)) + } +} diff --git a/DB/Sources/DB/Content/TimelineRecord.swift b/DB/Sources/DB/Content/TimelineRecord.swift index ebc3272..f64dce1 100644 --- a/DB/Sources/DB/Content/TimelineRecord.swift +++ b/DB/Sources/DB/Content/TimelineRecord.swift @@ -39,13 +39,14 @@ extension TimelineRecord { through: statusJoins, using: TimelineStatusJoin.status) .order(StatusRecord.Columns.createdAt.desc) - static let loadMores = hasMany(LoadMore.self) + static let account = belongsTo(AccountRecord.self, using: ForeignKey([Columns.accountId])) + static let loadMores = hasMany(LoadMoreRecord.self) var statuses: QueryInterfaceRequest { StatusInfo.request(request(for: Self.statuses)) } - var loadMores: QueryInterfaceRequest { + var loadMores: QueryInterfaceRequest { request(for: Self.loadMores) } diff --git a/DB/Sources/DB/Entities/LoadMore.swift b/DB/Sources/DB/Entities/LoadMore.swift index e8aacb1..2333f76 100644 --- a/DB/Sources/DB/Entities/LoadMore.swift +++ b/DB/Sources/DB/Entities/LoadMore.swift @@ -4,24 +4,14 @@ import Foundation import GRDB import Mastodon -public struct LoadMore: Codable, Hashable { - public let timelineId: String +public struct LoadMore: Hashable { + public let timeline: Timeline public let afterStatusId: String } -extension LoadMore: FetchableRecord, PersistableRecord { - public static func databaseJSONDecoder(for column: String) -> JSONDecoder { - MastodonDecoder() - } - - public static func databaseJSONEncoder(for column: String) -> JSONEncoder { - MastodonEncoder() - } -} - -extension LoadMore { - enum Columns { - static let timelineId = Column(LoadMore.CodingKeys.timelineId) - static let belowStatusId = Column(LoadMore.CodingKeys.afterStatusId) +public extension LoadMore { + enum Direction { + case up + case down } } diff --git a/DB/Sources/DB/Extensions/Filter+Extensions.swift b/DB/Sources/DB/Extensions/Filter+Extensions.swift index 456c0c2..7eca9dd 100644 --- a/DB/Sources/DB/Extensions/Filter+Extensions.swift +++ b/DB/Sources/DB/Extensions/Filter+Extensions.swift @@ -30,8 +30,8 @@ extension Filter { } extension Array where Element == StatusInfo { - func filtered(filters: [Filter], context: Filter.Context) -> Self { - guard let regEx = filters.filter({ $0.context.contains(context) }).regularExpression() else { return self } + func filtered(regularExpression: String?) -> Self { + guard let regEx = regularExpression else { return self } return filter { $0.filterableContent.range(of: regEx, options: [.regularExpression, .caseInsensitive]) == nil } } diff --git a/Mastodon/Sources/Mastodon/Entities/Filter.swift b/Mastodon/Sources/Mastodon/Entities/Filter.swift index 78e2441..34660b1 100644 --- a/Mastodon/Sources/Mastodon/Entities/Filter.swift +++ b/Mastodon/Sources/Mastodon/Entities/Filter.swift @@ -36,10 +36,12 @@ extension Array where Element == Filter { // swiftlint:disable line_length // Adapted from https://github.com/tootsuite/mastodon/blob/bf477cee9f31036ebf3d164ddec1cebef5375513/app/javascript/mastodon/selectors/index.js#L43 // swiftlint:enable line_length - public func regularExpression() -> String? { - guard !isEmpty else { return nil } + public func regularExpression(context: Filter.Context) -> String? { + let inContext = filter { $0.context.contains(context) } - return map { + guard !inContext.isEmpty else { return nil } + + return inContext.map { var expression = NSRegularExpression.escapedPattern(for: $0.phrase) if $0.wholeWord { diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift b/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift index 8bf8f0b..7a14413 100644 --- a/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift +++ b/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift @@ -1,5 +1,44 @@ // Copyright © 2020 Metabolist. All rights reserved. import DB +import MastodonAPI public typealias Timeline = DB.Timeline + +extension Timeline { + var endpoint: StatusesEndpoint { + switch self { + case .home: + return .timelinesHome + case .local: + return .timelinesPublic(local: true) + case .federated: + return .timelinesPublic(local: false) + case let .list(list): + return .timelinesList(id: list.id) + case let .tag(tag): + return .timelinesTag(tag) + case let .profile(accountId, profileCollection): + let excludeReplies: Bool + let onlyMedia: Bool + + switch profileCollection { + case .statuses: + excludeReplies = true + onlyMedia = false + case .statusesAndReplies: + excludeReplies = false + onlyMedia = false + case .media: + excludeReplies = true + onlyMedia = true + } + + return .accountsStatuses( + id: accountId, + excludeReplies: excludeReplies, + onlyMedia: onlyMedia, + pinned: false) + } + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/LoadMoreService.swift b/ServiceLayer/Sources/ServiceLayer/Services/LoadMoreService.swift new file mode 100644 index 0000000..f92101e --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/LoadMoreService.swift @@ -0,0 +1,28 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import DB +import MastodonAPI + +public struct LoadMoreService { + private let loadMore: LoadMore + private let mastodonAPIClient: MastodonAPIClient + private let contentDatabase: ContentDatabase + + init(loadMore: LoadMore, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { + self.loadMore = loadMore + self.mastodonAPIClient = mastodonAPIClient + self.contentDatabase = contentDatabase + } +} + +public extension LoadMoreService { + func request(direction: LoadMore.Direction) -> AnyPublisher { + mastodonAPIClient.pagedRequest( + loadMore.timeline.endpoint, + maxID: direction == .down ? loadMore.afterStatusId : nil, + minID: direction == .up ? loadMore.afterStatusId : nil) + .flatMap { contentDatabase.insert(statuses: $0.result, timeline: loadMore.timeline) } + .eraseToAnyPublisher() + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift index 72b9cfd..1c29cda 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/StatusListService.swift @@ -76,41 +76,3 @@ public extension StatusListService { requestClosure(maxID, minID) } } - -private extension Timeline { - var endpoint: StatusesEndpoint { - switch self { - case .home: - return .timelinesHome - case .local: - return .timelinesPublic(local: true) - case .federated: - return .timelinesPublic(local: false) - case let .list(list): - return .timelinesList(id: list.id) - case let .tag(tag): - return .timelinesTag(tag) - case let .profile(accountId, profileCollection): - let excludeReplies: Bool - let onlyMedia: Bool - - switch profileCollection { - case .statuses: - excludeReplies = true - onlyMedia = false - case .statusesAndReplies: - excludeReplies = false - onlyMedia = false - case .media: - excludeReplies = true - onlyMedia = true - } - - return .accountsStatuses( - id: accountId, - excludeReplies: excludeReplies, - onlyMedia: onlyMedia, - pinned: false) - } - } -} diff --git a/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift b/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift index 4bb1bf6..dce734f 100644 --- a/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift +++ b/ViewModels/Sources/ViewModels/LoadMoreViewModel.swift @@ -1,5 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. -public struct LoadMoreViewModel { +import Combine +public class LoadMoreViewModel: ObservableObject { + @Published var loading = false }