diff --git a/DB/Sources/DB/Content/TimelineRecord.swift b/DB/Sources/DB/Content/TimelineRecord.swift index 9180709..0cbcc4e 100644 --- a/DB/Sources/DB/Content/TimelineRecord.swift +++ b/DB/Sources/DB/Content/TimelineRecord.swift @@ -44,7 +44,7 @@ extension TimelineRecord { id = timeline.id switch timeline { - case .home, .local, .federated: + case .home, .local, .federated, .favorites: listId = nil listTitle = nil tag = nil diff --git a/DB/Sources/DB/Entities/Timeline.swift b/DB/Sources/DB/Entities/Timeline.swift index 477b41f..5e59fe6 100644 --- a/DB/Sources/DB/Entities/Timeline.swift +++ b/DB/Sources/DB/Entities/Timeline.swift @@ -10,6 +10,7 @@ public enum Timeline: Hashable { case list(List) case tag(String) case profile(accountId: Account.Id, profileCollection: ProfileCollection) + case favorites } public extension Timeline { @@ -18,7 +19,7 @@ public extension Timeline { static let unauthenticatedDefaults: [Timeline] = [.local, .federated] static let authenticatedDefaults: [Timeline] = [.home, .local, .federated] - var filterContext: Filter.Context { + var filterContext: Filter.Context? { switch self { case .home, .list: return .home @@ -26,6 +27,8 @@ public extension Timeline { return .public case .profile: return .account + default: + return nil } } } @@ -45,6 +48,8 @@ extension Timeline: Identifiable { return "tag-".appending(tag).lowercased() case let .profile(accountId, profileCollection): return "profile-\(accountId)-\(profileCollection)" + case .favorites: + return "favorites" } } } diff --git a/DB/Sources/DB/Extensions/Timeline+Extensions.swift b/DB/Sources/DB/Extensions/Timeline+Extensions.swift index fc7522f..7dfdb49 100644 --- a/DB/Sources/DB/Extensions/Timeline+Extensions.swift +++ b/DB/Sources/DB/Extensions/Timeline+Extensions.swift @@ -24,6 +24,8 @@ extension Timeline { self = .tag(tag) case (_, _, _, _, .some(let accountId), .some(let profileCollection)): self = .profile(accountId: accountId, profileCollection: profileCollection) + case (Timeline.favorites.id, _, _, _, _, _): + self = .favorites default: return nil } diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index fcf94cf..e486776 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -25,6 +25,7 @@ "attachment.sensitive-content" = "Sensitive content"; "attachment.media-hidden" = "Media hidden"; "cancel" = "Cancel"; +"favorites" = "Favorites"; "registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue"; "registration.username" = "Username"; "registration.email" = "Email"; diff --git a/Mastodon/Sources/Mastodon/Entities/Filter.swift b/Mastodon/Sources/Mastodon/Entities/Filter.swift index 62d58e4..4ff1fcc 100644 --- a/Mastodon/Sources/Mastodon/Entities/Filter.swift +++ b/Mastodon/Sources/Mastodon/Entities/Filter.swift @@ -38,7 +38,9 @@ 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(context: Filter.Context) -> String? { + public func regularExpression(context: Filter.Context?) -> String? { + guard let context = context else { return nil } + let inContext = filter { $0.context.contains(context) } guard !inContext.isEmpty else { return nil } diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift index 73db18a..a5edf70 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusesEndpoint.swift @@ -10,6 +10,7 @@ public enum StatusesEndpoint { case timelinesHome case timelinesList(id: List.Id) case accountsStatuses(id: Account.Id, excludeReplies: Bool, onlyMedia: Bool, pinned: Bool) + case favourites } extension StatusesEndpoint: Endpoint { @@ -21,6 +22,8 @@ extension StatusesEndpoint: Endpoint { return defaultContext + ["timelines"] case .accountsStatuses: return defaultContext + ["accounts"] + case .favourites: + return defaultContext } } @@ -36,6 +39,8 @@ extension StatusesEndpoint: Endpoint { return ["list", id] case let .accountsStatuses(id, _, _, _): return [id, "statuses"] + case .favourites: + return ["favourites"] } } diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift b/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift index 7a14413..254f8ae 100644 --- a/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift +++ b/ServiceLayer/Sources/ServiceLayer/Entities/Timeline.swift @@ -39,6 +39,8 @@ extension Timeline { excludeReplies: excludeReplies, onlyMedia: onlyMedia, pinned: false) + case .favorites: + return .favourites } } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift index aa948f0..0b051ce 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/TimelineService.swift @@ -15,27 +15,15 @@ public struct TimelineService { private let timeline: Timeline private let mastodonAPIClient: MastodonAPIClient private let contentDatabase: ContentDatabase - private let nextPageMaxIdSubject: CurrentValueSubject + private let nextPageMaxIdSubject = PassthroughSubject() 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.dropFirst().eraseToAnyPublisher() + nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher() if case let .tag(tag) = timeline { title = Just("#".appending(tag)).eraseToAnyPublisher() @@ -58,9 +46,9 @@ extension TimelineService: CollectionService { public func request(maxId: String?, minId: String?) -> AnyPublisher { mastodonAPIClient.pagedRequest(timeline.endpoint, maxId: maxId, minId: minId) .handleEvents(receiveOutput: { - guard let maxId = $0.info.maxId, maxId < nextPageMaxIdSubject.value else { return } - - nextPageMaxIdSubject.send(maxId) + if let maxId = $0.info.maxId { + nextPageMaxIdSubject.send(maxId) + } }) .flatMap { contentDatabase.insert(statuses: $0.result, timeline: timeline) } .eraseToAnyPublisher() diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index 614541e..840af2c 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -98,7 +98,7 @@ extension CollectionItemsViewModel: CollectionViewModel { .eraseToAnyPublisher() self.hasRequestedUsingMarker = true } else { - publisher = collectionService.request(maxId: maxId, minId: minId) + publisher = collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId) } publisher @@ -294,6 +294,17 @@ private extension CollectionItemsViewModel { viewModelCache = viewModelCache.filter { itemsSet.contains($0.key) } } + func realMaxId(maxId: String?) -> String? { + guard let maxId = maxId else { return nil } + + guard let markerTimeline = collectionService.markerTimeline, + identification.appPreferences.positionBehavior(markerTimeline: markerTimeline) == .rememberPosition, + let lastItemId = items.value.last?.last?.itemId + else { return maxId } + + return min(maxId, lastItemId) + } + func idForScrollPositionMaintenance(newItems: [[CollectionItem]]) -> CollectionItem.Id? { let flatItems = items.value.reduce([], +) let flatNewItems = newItems.reduce([], +) diff --git a/ViewModels/Sources/ViewModels/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/NavigationViewModel.swift index 4eb0dcd..da58e40 100644 --- a/ViewModels/Sources/ViewModels/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/NavigationViewModel.swift @@ -95,7 +95,7 @@ public extension NavigationViewModel { switch timeline { case .home, .list: return identification.identity.handle - case .local, .federated, .tag, .profile: + case .local, .federated, .tag, .profile, .favorites: return identification.identity.instance?.uri ?? "" } } @@ -140,6 +140,12 @@ public extension NavigationViewModel { case notifications case messages } + + func favoritesViewModel() -> CollectionViewModel { + CollectionItemsViewModel( + collectionService: identification.service.service(timeline: .favorites), + identification: identification) + } } extension NavigationViewModel.Tab: Identifiable { diff --git a/Views/SecondaryNavigationView.swift b/Views/SecondaryNavigationView.swift index 75289a6..e82fe81 100644 --- a/Views/SecondaryNavigationView.swift +++ b/Views/SecondaryNavigationView.swift @@ -56,6 +56,10 @@ struct SecondaryNavigationView: View { NavigationLink(destination: ListsView(viewModel: .init(identification: viewModel.identification))) { Label("secondary-navigation.lists", systemImage: "scroll") } + NavigationLink(destination: TableView(viewModel: viewModel.favoritesViewModel()) + .navigationTitle(Text("favorites"))) { + Label("favorites", systemImage: "star.fill") + } } Section { NavigationLink( diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index 83ae37f..3671048 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -159,6 +159,8 @@ private extension Timeline { return "#" + tag case .profile: return "" + case .favorites: + return NSLocalizedString("favorites", comment: "") } } @@ -170,6 +172,7 @@ private extension Timeline { case .list: return "scroll" case .tag: return "number" case .profile: return "person" + case .favorites: return "star.fill" } } }