Don't fail decoding when one status fails to decode

Also remove old workaround for bad dates from #477

Closes #478
This commit is contained in:
Shadowfacts 2024-06-08 13:29:56 -07:00
parent e6d9a33dbf
commit c2232a5e14
13 changed files with 70 additions and 36 deletions

View File

@ -42,8 +42,7 @@ public struct Client: Sendable {
} else if let date = iso8601.date(from: str) { } else if let date = iso8601.date(from: str) {
return date return date
} else { } else {
// throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)")) throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
return Date(timeIntervalSinceReferenceDate: 0)
} }
}) })
@ -205,8 +204,8 @@ public struct Client: Sendable {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials") return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
} }
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> { public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites") var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
request.range = range request.range = range
return request return request
} }
@ -457,14 +456,13 @@ public struct Client: Sendable {
} }
// MARK: - Timelines // MARK: - Timelines
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> { public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
return timeline.request(range: range) return timeline.request(range: range)
} }
// MARK: - Bookmarks // MARK: - Bookmarks
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> { public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks") var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
request.range = range request.range = range
return request return request
} }
@ -492,7 +490,7 @@ public struct Client: Sendable {
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters) return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
} }
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> { public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
var parameters: [Parameter] = [] var parameters: [Parameter] = []
if let limit { if let limit {
parameters.append("limit" => limit) parameters.append("limit" => limit)

View File

@ -95,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
return request return request
} }
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> { public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [ var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
"only_media" => onlyMedia, "only_media" => onlyMedia,
"pinned" => pinned, "pinned" => pinned,
"exclude_replies" => excludeReplies, "exclude_replies" => excludeReplies,

View File

@ -10,7 +10,7 @@ import Foundation
public struct SearchResults: Decodable, Sendable { public struct SearchResults: Decodable, Sendable {
public let accounts: [Account] public let accounts: [Account]
public let statuses: [Status] public let statuses: [TryDecode<Status>]
public let hashtags: [Hashtag] public let hashtags: [Hashtag]
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {

View File

@ -32,8 +32,8 @@ extension Timeline {
} }
} }
func request(range: RequestRange) -> Request<[Status]> { func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint) var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
if case .public(true) = self { if case .public(true) = self {
request.queryParameters.append("local" => true) request.queryParameters.append("local" => true)
} }

View File

@ -0,0 +1,32 @@
//
// TryDecode.swift
// Pachyderm
//
// Created by Shadowfacts on 6/8/24.
//
import Foundation
public enum TryDecode<T: Decodable>: Decodable {
case error(String)
case value(T)
public init(from decoder: any Decoder) throws {
do {
self = .value(try T(from: decoder))
} catch {
self = .error(error.localizedDescription)
}
}
public var value: T? {
if case .value(let value) = self {
value
} else {
nil
}
}
}
extension TryDecode: Sendable where T: Sendable {
}

View File

@ -232,7 +232,7 @@ class ConversationViewController: UIViewController {
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true) let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
do { do {
let (results, _) = try await mastodonController.run(request) let (results, _) = try await mastodonController.run(request)
guard let status = results.statuses.first(where: { $0.url?.serialized() == effectiveURL }) else { guard let status = results.statuses.compactMap(\.value).first(where: { $0.url?.serialized() == effectiveURL }) else {
throw UnableToResolveError() throw UnableToResolveError()
} }
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) _ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)

View File

@ -123,7 +123,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
private func loadTrendingStatuses() async { private func loadTrendingStatuses() async {
let statuses: [Status] let statuses: [Status]
do { do {
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0 statuses = try await mastodonController.run(Client.getTrendingStatuses()).0.compactMap(\.value)
} catch { } catch {
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>() let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
await MainActor.run { await MainActor.run {

View File

@ -277,7 +277,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
let linksReq = Client.getTrendingLinks(limit: 10) let linksReq = Client.getTrendingLinks(limit: 10)
async let links = try? mastodonController.run(linksReq).0 async let links = try? mastodonController.run(linksReq).0
let statusesReq = Client.getTrendingStatuses(limit: 10) let statusesReq = Client.getTrendingStatuses(limit: 10)
async let statuses = try? mastodonController.run(statusesReq).0 async let statuses = try? mastodonController.run(statusesReq).0.compactMap(\.value)
if let links = await links { if let links = await links {
if snapshot.sectionIdentifiers.contains(.profileSuggestions) { if snapshot.sectionIdentifiers.contains(.profileSuggestions) {
@ -332,7 +332,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
do { do {
let request = Client.getTrendingStatuses(offset: origSnapshot.itemIdentifiers(inSection: .trendingStatuses).count) let request = Client.getTrendingStatuses(offset: origSnapshot.itemIdentifiers(inSection: .trendingStatuses).count)
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
await mastodonController.persistentContainer.addAll(statuses: statuses) await mastodonController.persistentContainer.addAll(statuses: statuses)

View File

@ -17,7 +17,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
let mastodonController: MastodonController let mastodonController: MastodonController
private let predicate: (StatusMO) -> Bool private let predicate: (StatusMO) -> Bool
private let predicateTitle: String private let predicateTitle: String
private let request: (RequestRange) -> Request<[Status]> private let request: (RequestRange) -> Request<[TryDecode<Status>]>
var collectionView: UICollectionView! { var collectionView: UICollectionView! {
view as? UICollectionView view as? UICollectionView
@ -28,7 +28,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
private var newer: RequestRange? private var newer: RequestRange?
private var older: RequestRange? private var older: RequestRange?
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) { init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[TryDecode<Status>]>, mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.predicate = predicate self.predicate = predicate
self.predicateTitle = predicateTitle self.predicateTitle = predicateTitle
@ -140,7 +140,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
do { do {
let req = request(.count(Self.pageSize)) let req = request(.count(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req) let (tryStatuses, pagination) = try await mastodonController.run(req)
let statuses = tryStatuses.compactMap(\.value)
newer = pagination?.newer newer = pagination?.newer
older = pagination?.older older = pagination?.older
@ -180,7 +181,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
do { do {
let req = request(older.withCount(Self.pageSize)) let req = request(older.withCount(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req) let (tryStatuses, pagination) = try await mastodonController.run(req)
let statuses = tryStatuses.compactMap(\.value)
self.older = pagination?.older self.older = pagination?.older
await mastodonController.persistentContainer.addAll(statuses: statuses) await mastodonController.persistentContainer.addAll(statuses: statuses)
@ -278,7 +280,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
Task { Task {
do { do {
let req = request(newer.withCount(Self.pageSize)) let req = request(newer.withCount(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req) let (tryStatuses, pagination) = try await mastodonController.run(req)
let statuses = tryStatuses.compactMap(\.value)
self.newer = pagination?.newer self.newer = pagination?.newer
await mastodonController.persistentContainer.addAll(statuses: statuses) await mastodonController.persistentContainer.addAll(statuses: statuses)

View File

@ -310,7 +310,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
} }
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false) let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -526,7 +526,7 @@ extension ProfileStatusesViewController {
extension ProfileStatusesViewController: TimelineLikeControllerDataSource { extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
typealias TimelineItem = String // status ID typealias TimelineItem = String // status ID
private func request(for range: RequestRange = .default) -> Request<[Status]> { private func request(for range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
switch kind { switch kind {
case .statuses: case .statuses:
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true) return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
@ -539,7 +539,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
func loadInitial() async throws -> [String] { func loadInitial() async throws -> [String] {
let request = request() let request = request()
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
if !statuses.isEmpty { if !statuses.isEmpty {
newer = .after(id: statuses.first!.id, count: nil) newer = .after(id: statuses.first!.id, count: nil)
@ -559,7 +559,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
} }
let request = request(for: newer) let request = request(for: newer)
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
throw Error.allCaughtUp throw Error.allCaughtUp
@ -580,7 +580,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
} }
let request = request(for: older) let request = request(for: older)
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
return [] return []

View File

@ -53,7 +53,7 @@ struct ReportAddStatusView: View {
.task { @MainActor in .task { @MainActor in
do { do {
let req = Account.getStatuses(report.accountID, range: .count(40), excludeReplies: false, excludeReblogs: true) let req = Account.getStatuses(report.accountID, range: .count(40), excludeReplies: false, excludeReblogs: true)
let (statuses, _) = try await mastodonController.run(req) let statuses = try await mastodonController.run(req).0.compactMap(\.value)
await mastodonController.persistentContainer.addAll(statuses: statuses) await mastodonController.persistentContainer.addAll(statuses: statuses)
self.statuses = statuses.compactMap { mastodonController.persistentContainer.status(for: $0.id) } self.statuses = statuses.compactMap { mastodonController.persistentContainer.status(for: $0.id) }
} catch { } catch {

View File

@ -266,7 +266,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
guard self.currentQuery == query else { return } guard self.currentQuery == query else { return }
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
addAccounts(results.accounts) addAccounts(results.accounts)
addStatuses(results.statuses) addStatuses(results.statuses.compactMap(\.value))
} completion: { } completion: {
DispatchQueue.main.async { DispatchQueue.main.async {
self.showSearchResults(results) self.showSearchResults(results)
@ -299,7 +299,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
} }
if !results.statuses.isEmpty && resultTypes.contains(.statuses) { if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses) snapshot.appendItems(results.statuses.compactMap(\.value).map { .status($0.id, .unknown) }, toSection: .statuses)
} }
dataSource.apply(snapshot) dataSource.apply(snapshot)

View File

@ -565,7 +565,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
do { do {
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home)) let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home))
async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0 async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0 // TODO: consider replacing undecodable statuses here with items to indicate that to the user
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0.compactMap(\.value)
let allStatuses = try await [status] + olderStatuses let allStatuses = try await [status] + olderStatuses
await mastodonController.persistentContainer.addAll(statuses: allStatuses) await mastodonController.persistentContainer.addAll(statuses: allStatuses)
@ -1100,7 +1101,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
func loadInitial() async throws -> [TimelineItem] { func loadInitial() async throws -> [TimelineItem] {
let request = Client.getStatuses(timeline: timeline, range: .count(TimelineViewController.pageSize)) let request = Client.getStatuses(timeline: timeline, range: .count(TimelineViewController.pageSize))
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -1119,7 +1120,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize) let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize)
let request = Client.getStatuses(timeline: timeline, range: newer) let request = Client.getStatuses(timeline: timeline, range: newer)
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
throw TimelineViewController.Error.allCaughtUp throw TimelineViewController.Error.allCaughtUp
@ -1143,7 +1144,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
let older = RequestRange.before(id: id, count: TimelineViewController.pageSize) let older = RequestRange.before(id: id, count: TimelineViewController.pageSize)
let request = Client.getStatuses(timeline: timeline, range: older) let request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
return [] return []
@ -1181,7 +1182,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
} }
let request = Client.getStatuses(timeline: timeline, range: range) let request = Client.getStatuses(timeline: timeline, range: range)
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
return [] return []