Change home timeline

This commit is contained in:
Marcin Czachurski 2023-10-20 17:35:11 +02:00
parent bb15356e88
commit f063338ce4
30 changed files with 552 additions and 1742 deletions

View File

@ -29,10 +29,16 @@ import ClientKit
public var statusesCount: Int32
public var url: URL?
public var username: String
@Relationship(deleteRule: .cascade, inverse: \StatusData.pixelfedAccount) public var statuses: [StatusData]
/// Last status seen on home timeline by the user.
public var lastSeenStatusId: String?
/// Last status loaded on home timeline.
public var lastLoadedStatusId: String?
@Relationship(deleteRule: .cascade, inverse: \ViewedStatus.pixelfedAccount) public var viewedStatuses: [ViewedStatus]
@Relationship(deleteRule: .cascade, inverse: \AccountRelationship.pixelfedAccount) public var accountRelationships: [AccountRelationship]
public var lastSeenStatusId: String?
init(
accessToken: String? = nil,
@ -55,7 +61,6 @@ import ClientKit
statusesCount: Int32 = .zero,
url: URL? = nil,
username: String = "",
statuses: [StatusData] = [],
viewedStatuses: [ViewedStatus] = [],
accountRelationships: [AccountRelationship] = [],
lastSeenStatusId: String? = nil
@ -80,7 +85,6 @@ import ClientKit
self.statusesCount = statusesCount
self.url = url
self.username = username
self.statuses = statuses
self.viewedStatuses = viewedStatuses
self.accountRelationships = accountRelationships
self.lastSeenStatusId = lastSeenStatusId

View File

@ -40,9 +40,9 @@ class AccountDataHandler {
func getAccountData(accountId: String, modelContext: ModelContext) -> AccountData? {
do {
var fetchDescriptor = FetchDescriptor<AccountData>(predicate: #Predicate { accountData in
accountData.id == accountId
})
var fetchDescriptor = FetchDescriptor<AccountData>(
predicate: #Predicate { $0.id == accountId}
)
fetchDescriptor.fetchLimit = 1
fetchDescriptor.includePendingChanges = true
@ -61,4 +61,20 @@ class AccountDataHandler {
CoreDataError.shared.handle(error, message: "Error during deleting account data (remove).")
}
}
func update(lastSeenStatusId: String?, lastLoadedStatusId: String?, accountId: String, modelContext: ModelContext) throws {
guard let accountDataFromDb = self.getAccountData(accountId: accountId, modelContext: modelContext) else {
return
}
if (accountDataFromDb.lastSeenStatusId ?? "0") < (lastSeenStatusId ?? "0") {
accountDataFromDb.lastSeenStatusId = lastSeenStatusId
}
if (accountDataFromDb.lastLoadedStatusId ?? "0") < (lastLoadedStatusId ?? "0") {
accountDataFromDb.lastLoadedStatusId = lastLoadedStatusId
}
try modelContext.save()
}
}

View File

@ -8,7 +8,7 @@ import Foundation
import SwiftData
@Model final public class AccountRelationship {
public var accountId: String
@Attribute(.unique) public var accountId: String
public var boostedStatusesMuted: Bool
public var pixelfedAccount: AccountData?

View File

@ -35,9 +35,9 @@ class AccountRelationshipHandler {
}
let newAccountRelationship = AccountRelationship(accountId: relationAccountId, boostedStatusesMuted: false, pixelfedAccount: accountDataFromDb)
accountDataFromDb.accountRelationships.append(newAccountRelationship)
modelContext.insert(newAccountRelationship)
accountDataFromDb.accountRelationships.append(newAccountRelationship)
accountRelationship = newAccountRelationship
}
@ -52,9 +52,9 @@ class AccountRelationshipHandler {
private func getAccountRelationship(for accountId: String, relation relationAccountId: String, modelContext: ModelContext) -> AccountRelationship? {
do {
var fetchDescriptor = FetchDescriptor<AccountRelationship>(predicate: #Predicate { accountRelationship in
accountRelationship.accountId == relationAccountId && accountRelationship.pixelfedAccount?.id == accountId
})
var fetchDescriptor = FetchDescriptor<AccountRelationship>(
predicate: #Predicate { $0.accountId == relationAccountId && $0.pixelfedAccount?.id == accountId }
)
fetchDescriptor.fetchLimit = 1
fetchDescriptor.includePendingChanges = true

View File

@ -1,121 +0,0 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import Foundation
import SwiftData
import PixelfedKit
@Model final public class AttachmentData {
public var id: String
@Attribute(.externalStorage) public var data: Data?
public var blurhash: String?
public var exifCamera: String?
public var exifCreatedDate: String?
public var exifExposure: String?
public var exifLens: String?
public var previewUrl: URL?
public var remoteUrl: URL?
public var statusId: String
public var text: String?
public var type: String
public var url: URL
public var metaImageWidth: Int32
public var metaImageHeight: Int32
public var order: Int32
public var statusRelation: StatusData?
init(
blurhash: String? = nil,
data: Data? = nil,
exifCamera: String? = nil,
exifCreatedDate: String? = nil,
exifExposure: String? = nil,
exifLens: String? = nil,
id: String,
previewUrl: URL? = nil,
remoteUrl: URL? = nil,
statusId: String,
text: String? = nil,
type: String = "",
url: URL,
metaImageWidth: Int32 = .zero,
metaImageHeight: Int32 = .zero,
order: Int32 = .zero,
statusRelation: StatusData? = nil
) {
self.blurhash = blurhash
self.data = data
self.exifCamera = exifCamera
self.exifCreatedDate = exifCreatedDate
self.exifExposure = exifExposure
self.exifLens = exifLens
self.id = id
self.previewUrl = previewUrl
self.remoteUrl = remoteUrl
self.statusId = statusId
self.text = text
self.type = type
self.url = url
self.metaImageWidth = metaImageWidth
self.metaImageHeight = metaImageHeight
self.order = order
self.statusRelation = statusRelation
}
}
extension AttachmentData: Identifiable {
}
extension AttachmentData: Comparable {
public static func < (lhs: AttachmentData, rhs: AttachmentData) -> Bool {
lhs.id < rhs.id
}
}
extension AttachmentData {
func copyFrom(_ attachment: MediaAttachment) {
self.id = attachment.id
self.url = attachment.url
self.blurhash = attachment.blurhash
self.previewUrl = attachment.previewUrl
self.remoteUrl = attachment.remoteUrl
self.text = attachment.description
self.type = attachment.type.rawValue
// We can set image width only when it wasn't previusly recalculated.
if let width = (attachment.meta as? ImageMetadata)?.original?.width, self.metaImageWidth <= 0 && width > 0 {
self.metaImageWidth = Int32(width)
}
// We can set image height only when it wasn't previusly recalculated.
if let height = (attachment.meta as? ImageMetadata)?.original?.height, self.metaImageHeight <= 0 && height > 0 {
self.metaImageHeight = Int32(height)
}
}
}
extension [AttachmentData] {
func getHighestImage() -> AttachmentData? {
var attachment = self.first
var imgHeight = 0.0
for item in self {
let attachmentheight = Double(item.metaImageHeight)
if attachmentheight > imgHeight {
attachment = item
imgHeight = attachmentheight
}
}
return attachment
}
}
extension AttachmentData {
func isFaulty() -> Bool {
return self.isDeleted // || self.isFault
}
}

View File

@ -1,28 +0,0 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import Foundation
import SwiftData
class AttachmentDataHandler {
public static let shared = AttachmentDataHandler()
private init() { }
func getDownloadedAttachmentData(accountId: String, length: Int, modelContext: ModelContext) -> [AttachmentData] {
do {
var fetchDescriptor = FetchDescriptor<AttachmentData>(predicate: #Predicate { attachmentData in
attachmentData.statusRelation?.pixelfedAccount?.id == accountId && attachmentData.data != nil
}, sortBy: [SortDescriptor(\.statusRelation?.id, order: .forward)])
fetchDescriptor.fetchLimit = length
fetchDescriptor.includePendingChanges = true
return try modelContext.fetch(fetchDescriptor)
} catch {
CoreDataError.shared.handle(error, message: "Error during fetching attachment data (getDownloadedAttachmentData).")
return []
}
}
}

View File

@ -1,207 +0,0 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import Foundation
import SwiftData
import PixelfedKit
@Model final public class StatusData {
public var id: String
public var accountAvatar: URL?
public var accountDisplayName: String?
public var accountId: String
public var accountUsername: String
public var applicationName: String?
public var applicationWebsite: URL?
public var bookmarked: Bool
public var content: String
public var createdAt: String
public var favourited: Bool
public var favouritesCount: Int32
public var inReplyToAccount: String?
public var inReplyToId: String?
public var muted: Bool
public var pinned: Bool
public var reblogged: Bool
public var reblogsCount: Int32
public var repliesCount: Int32
public var sensitive: Bool
public var spoilerText: String?
public var uri: String?
public var url: URL?
public var visibility: String
@Relationship(deleteRule: .cascade, inverse: \AttachmentData.statusRelation) public var attachmentsRelation: [AttachmentData]
public var pixelfedAccount: AccountData?
public var rebloggedStatusId: String?
public var rebloggedAccountAvatar: URL?
public var rebloggedAccountDisplayName: String?
public var rebloggedAccountId: String?
public var rebloggedAccountUsername: String?
init(
accountAvatar: URL? = nil,
accountDisplayName: String? = nil,
accountId: String = "",
accountUsername: String = "",
applicationName: String? = nil,
applicationWebsite: URL? = nil,
bookmarked: Bool = false,
content: String = "",
createdAt: String = "",
favourited: Bool = false,
favouritesCount: Int32 = .zero,
id: String = "",
inReplyToAccount: String? = nil,
inReplyToId: String? = nil,
muted: Bool = false,
pinned: Bool = false,
reblogged: Bool = false,
reblogsCount: Int32 = .zero,
repliesCount: Int32 = .zero,
sensitive: Bool = false,
spoilerText: String? = nil,
uri: String? = nil,
url: URL? = nil,
visibility: String = "",
attachmentsRelation: [AttachmentData] = [],
pixelfedAccount: AccountData? = nil,
rebloggedStatusId: String? = nil,
rebloggedAccountAvatar: URL? = nil,
rebloggedAccountDisplayName: String? = nil,
rebloggedAccountId: String? = nil,
rebloggedAccountUsername: String? = nil
) {
self.accountAvatar = accountAvatar
self.accountDisplayName = accountDisplayName
self.accountId = accountId
self.accountUsername = accountUsername
self.applicationName = applicationName
self.applicationWebsite = applicationWebsite
self.bookmarked = bookmarked
self.content = content
self.createdAt = createdAt
self.favourited = favourited
self.favouritesCount = favouritesCount
self.id = id
self.inReplyToAccount = inReplyToAccount
self.inReplyToId = inReplyToId
self.muted = muted
self.pinned = pinned
self.reblogged = reblogged
self.reblogsCount = reblogsCount
self.repliesCount = repliesCount
self.sensitive = sensitive
self.spoilerText = spoilerText
self.uri = uri
self.url = url
self.visibility = visibility
self.attachmentsRelation = attachmentsRelation
self.pixelfedAccount = pixelfedAccount
self.rebloggedStatusId = rebloggedStatusId
self.rebloggedAccountAvatar = rebloggedAccountAvatar
self.rebloggedAccountDisplayName = rebloggedAccountDisplayName
self.rebloggedAccountId = rebloggedAccountId
self.rebloggedAccountUsername = rebloggedAccountUsername
}
}
extension StatusData: Identifiable {
}
extension StatusData {
func attachments() -> [AttachmentData] {
return self.attachmentsRelation.sorted(by: { lhs, rhs in
lhs.order < rhs.order
})
}
}
extension StatusData {
func copyFrom(_ status: Status) {
if let reblog = status.reblog {
self.copyFrom(reblog)
self.id = status.id
self.rebloggedStatusId = reblog.id
self.rebloggedAccountAvatar = status.account.avatar
self.rebloggedAccountDisplayName = status.account.displayName
self.rebloggedAccountId = status.account.id
self.rebloggedAccountUsername = status.account.acct
} else {
self.id = status.id
self.createdAt = status.createdAt
self.accountAvatar = status.account.avatar
self.accountDisplayName = status.account.displayName
self.accountId = status.account.id
self.accountUsername = status.account.acct
self.applicationName = status.application?.name
self.applicationWebsite = status.application?.website
self.bookmarked = status.bookmarked
self.content = status.content.htmlValue
self.favourited = status.favourited
self.favouritesCount = Int32(status.favouritesCount)
self.inReplyToAccount = status.inReplyToAccount
self.inReplyToId = status.inReplyToId
self.muted = status.muted
self.pinned = status.pinned
self.reblogged = status.reblogged
self.reblogsCount = Int32(status.reblogsCount)
self.repliesCount = Int32(status.repliesCount)
self.sensitive = status.sensitive
self.spoilerText = status.spoilerText
self.uri = status.uri
self.url = status.url
self.visibility = status.visibility.rawValue
}
}
func updateFrom(_ status: Status) {
if let reblog = status.reblog {
self.updateFrom(reblog)
self.rebloggedAccountAvatar = status.account.avatar
self.rebloggedAccountDisplayName = status.account.displayName
self.rebloggedAccountId = status.account.id
self.rebloggedAccountUsername = status.account.acct
} else {
self.accountAvatar = status.account.avatar
self.accountDisplayName = status.account.displayName
self.accountUsername = status.account.acct
self.applicationName = status.application?.name
self.applicationWebsite = status.application?.website
self.bookmarked = status.bookmarked
self.content = status.content.htmlValue
self.favourited = status.favourited
self.favouritesCount = Int32(status.favouritesCount)
self.inReplyToAccount = status.inReplyToAccount
self.inReplyToId = status.inReplyToId
self.muted = status.muted
self.pinned = status.pinned
self.reblogged = status.reblogged
self.reblogsCount = Int32(status.reblogsCount)
self.repliesCount = Int32(status.repliesCount)
self.sensitive = status.sensitive
self.spoilerText = status.spoilerText
self.uri = status.uri
self.url = status.url
self.visibility = status.visibility.rawValue
}
}
}
public extension StatusData {
func getOrginalStatusId() -> String {
return self.rebloggedStatusId ?? self.id
}
}
extension StatusData {
func isFaulty() -> Bool {
return self.isDeleted // || self.isFault
}
}

View File

@ -1,115 +0,0 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import Foundation
import SwiftData
import PixelfedKit
class StatusDataHandler {
public static let shared = StatusDataHandler()
private init() { }
func getAllStatuses(accountId: String, modelContext: ModelContext) -> [StatusData] {
do {
var fetchDescriptor = FetchDescriptor<StatusData>(predicate: #Predicate { statusData in
statusData.pixelfedAccount?.id == accountId
}, sortBy: [SortDescriptor(\.id, order: .reverse)])
fetchDescriptor.includePendingChanges = true
return try modelContext.fetch(fetchDescriptor)
} catch {
CoreDataError.shared.handle(error, message: "Error during fetching status (getStatusData).")
return []
}
}
func getAllOlderStatuses(accountId: String, statusId: String, modelContext: ModelContext) -> [StatusData] {
do {
var fetchDescriptor = FetchDescriptor<StatusData>(predicate: #Predicate { statusData in
statusData.pixelfedAccount?.id == accountId && statusData.id < statusId
}, sortBy: [SortDescriptor(\.id, order: .reverse)])
fetchDescriptor.includePendingChanges = true
return try modelContext.fetch(fetchDescriptor)
} catch {
CoreDataError.shared.handle(error, message: "Error during fetching status (getStatusData).")
return []
}
}
func getStatusData(accountId: String, statusId: String, modelContext: ModelContext) -> StatusData? {
do {
var fetchDescriptor = FetchDescriptor<StatusData>(predicate: #Predicate { statusData in
statusData.pixelfedAccount?.id == accountId && statusData.id == statusId
})
fetchDescriptor.fetchLimit = 1
fetchDescriptor.includePendingChanges = true
return try modelContext.fetch(fetchDescriptor).first
} catch {
CoreDataError.shared.handle(error, message: "Error during fetching status (getStatusData).")
return nil
}
}
func getMaximumStatus(accountId: String, modelContext: ModelContext) -> StatusData? {
do {
var fetchDescriptor = FetchDescriptor<StatusData>(predicate: #Predicate { statusData in
statusData.pixelfedAccount?.id == accountId
}, sortBy: [SortDescriptor(\.id, order: .reverse)])
fetchDescriptor.fetchLimit = 1
fetchDescriptor.includePendingChanges = true
let statuses = try modelContext.fetch(fetchDescriptor)
return statuses.first
} catch {
CoreDataError.shared.handle(error, message: "Error during fetching maximum status (getMaximumStatus).")
return nil
}
}
func getMinimumStatus(accountId: String, modelContext: ModelContext) -> StatusData? {
do {
var fetchDescriptor = FetchDescriptor<StatusData>(predicate: #Predicate { statusData in
statusData.pixelfedAccount?.id == accountId
}, sortBy: [SortDescriptor(\.id, order: .forward)])
fetchDescriptor.fetchLimit = 1
fetchDescriptor.includePendingChanges = true
let statuses = try modelContext.fetch(fetchDescriptor)
return statuses.first
} catch {
CoreDataError.shared.handle(error, message: "Error during fetching minimum status (getMinimumtatus).")
return nil
}
}
func remove(accountId: String, statusId: String, modelContext: ModelContext) {
let status = self.getStatusData(accountId: accountId, statusId: statusId, modelContext: modelContext)
guard let status else {
return
}
do {
modelContext.delete(status)
try modelContext.save()
} catch {
CoreDataError.shared.handle(error, message: "Error during deleting status (remove).")
}
}
func setFavourited(accountId: String, statusId: String, modelContext: ModelContext) {
if let statusData = self.getStatusData(accountId: accountId, statusId: statusId, modelContext: modelContext) {
statusData.favourited = true
do {
try modelContext.save()
} catch {
CoreDataError.shared.handle(error, message: "Error during deleting status (setFavourited).")
}
}
}
}

View File

@ -13,8 +13,6 @@ public class SwiftDataHandler {
lazy var sharedModelContainer: ModelContainer = {
let schema = Schema([
AttachmentData.self,
StatusData.self,
ApplicationSettings.self,
AccountData.self,
ViewedStatus.self,

View File

@ -8,7 +8,7 @@ import Foundation
import SwiftData
@Model final public class ViewedStatus {
public var id: String
@Attribute(.unique) public var id: String
public var reblogId: String?
public var date: Date
public var pixelfedAccount: AccountData?

View File

@ -12,6 +12,27 @@ class ViewedStatusHandler {
public static let shared = ViewedStatusHandler()
private init() { }
/// Append new visible statuses to database.
func append(contentsOf statuses: [Status], accountId: String, modelContext: ModelContext) throws {
guard let accountDataFromDb = AccountDataHandler.shared.getAccountData(accountId: accountId, modelContext: modelContext) else {
return
}
for status in statuses {
guard self.getViewedStatus(accountId: accountId, statusId: status.id, modelContext: modelContext) == nil else {
continue
}
let viewedStatus = ViewedStatus(id: status.id, reblogId: status.reblog?.id, date: Date())
modelContext.insert(viewedStatus)
viewedStatus.pixelfedAccount = accountDataFromDb
accountDataFromDb.viewedStatuses.append(viewedStatus)
}
try modelContext.save()
}
/// Check if given status (real picture) has been already visible on the timeline (during last month).
func hasBeenAlreadyOnTimeline(accountId: String, status: Status, modelContext: ModelContext) -> Bool {
guard let reblog = status.reblog else {
@ -20,9 +41,9 @@ class ViewedStatusHandler {
do {
let reblogId = reblog.id
var fetchDescriptor = FetchDescriptor<ViewedStatus>(predicate: #Predicate { viewedStatus in
(viewedStatus.id == reblogId || viewedStatus.reblogId == reblogId) && viewedStatus.pixelfedAccount?.id == accountId
})
var fetchDescriptor = FetchDescriptor<ViewedStatus>(
predicate: #Predicate { $0.pixelfedAccount?.id == accountId && $0.id != status.id && ($0.id == reblogId || $0.reblogId == reblogId) }
)
fetchDescriptor.fetchLimit = 1
fetchDescriptor.includePendingChanges = true
@ -46,11 +67,28 @@ class ViewedStatusHandler {
}
/// Mark to delete statuses older then one month.
func deleteOldViewedStatuses(modelContext: ModelContext) {
func deleteOldViewedStatuses(modelContext: ModelContext) throws {
let oldViewedStatuses = self.getOldViewedStatuses(modelContext: modelContext)
for status in oldViewedStatuses {
modelContext.delete(status)
}
try modelContext.save()
}
private func getViewedStatus(accountId: String, statusId: String, modelContext: ModelContext) -> ViewedStatus? {
do {
var fetchDescriptor = FetchDescriptor<ViewedStatus>(
predicate: #Predicate { $0.id == statusId && $0.pixelfedAccount?.id == accountId }
)
fetchDescriptor.fetchLimit = 1
fetchDescriptor.includePendingChanges = true
return try modelContext.fetch(fetchDescriptor).first
} catch {
CoreDataError.shared.handle(error, message: "Error during fetching viewed statuses (getOldViewedStatuses).")
return nil
}
}
private func getOldViewedStatuses(modelContext: ModelContext) -> [ViewedStatus] {
@ -59,9 +97,9 @@ class ViewedStatusHandler {
}
do {
var fetchDescriptor = FetchDescriptor<ViewedStatus>(predicate: #Predicate { viewedStatus in
viewedStatus.date < date
})
var fetchDescriptor = FetchDescriptor<ViewedStatus>(
predicate: #Predicate { $0.date < date }
)
fetchDescriptor.includePendingChanges = true
return try modelContext.fetch(fetchDescriptor)

View File

@ -1296,40 +1296,6 @@
}
}
},
"global.error.canceledImageDownload" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Download image has been canceled."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "La descarga de la imagen ha sido cancelada."
}
},
"eu" : {
"stringUnit" : {
"state" : "translated",
"value" : "Irudiaren deskarga bertan behera utzi da."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Le téléchargement de l'image a été annulé."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pobieranie zdjęcia zostało anulowane."
}
}
}
},
"global.error.cannotConfigureTransactionListener" : {
"localizations" : {
"en" : {
@ -1806,40 +1772,6 @@
}
}
},
"global.error.statusesNotRetrieved" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Statuses not retrieved."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "No se pudieron obtener los estados."
}
},
"eu" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ez dira egoerak eskuratu."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Statuts non récupérés."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Statusy nie zostały pobrane."
}
}
}
},
"global.title.close" : {
"comment" : "Close",
"localizations" : {
@ -2060,40 +1992,6 @@
}
}
},
"home.title.noPhotos" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unfortunately, there are no photos here."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Desafortunadamente, no hay fotos aquí."
}
},
"eu" : {
"stringUnit" : {
"state" : "translated",
"value" : "Argazkirik ez."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Malheureusement, il n'y a pas de photos ici."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Niestety nie ma jeszcze żadnych zdjęć."
}
}
}
},
"instance.error.loadingDataFailed" : {
"localizations" : {
"en" : {

View File

@ -21,7 +21,7 @@ public class ErrorService {
case is LocalizedError:
ToastrService.shared.showError(title: message, subtitle: error.localizedDescription)
default:
ToastrService.shared.showError(subtitle: localizedMessage)
ToastrService.shared.showError(title: "", subtitle: localizedMessage)
}
}
@ -35,7 +35,7 @@ public class ErrorService {
case is LocalizedError:
ToastrService.shared.showError(localizedMessage: localizedMessage, subtitle: error.localizedDescription)
default:
ToastrService.shared.showError(subtitle: localizedMessage)
ToastrService.shared.showError(title: "", subtitle: localizedMessage)
}
}

View File

@ -1,6 +1,9 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"global.error.downloadingImageFailed" : {
"localizations" : {
"en" : {
@ -24,6 +27,7 @@
}
},
"global.error.unexpected" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {

View File

@ -39,7 +39,7 @@ public class ToastrService {
Drops.show(drop)
}
public func showError(title: LocalizedStringResource = LocalizedStringResource("global.error.unexpected"), imageSystemName: String = "ant.circle.fill", subtitle: String? = nil) {
public func showError(title: LocalizedStringResource, imageSystemName: String = "ant.circle.fill", subtitle: String? = nil) {
let image = self.createImage(systemName: imageSystemName, color: UIColor(Color.accentColor))
self.showError(title: title.key, image: image, subtitle: subtitle)
}
@ -49,12 +49,12 @@ public class ToastrService {
self.showError(title: localizedMessage, image: image, subtitle: subtitle)
}
public func showError(title: LocalizedStringResource = LocalizedStringResource("global.error.unexpected"), imageName: String, subtitle: String? = nil) {
public func showError(title: LocalizedStringResource, imageName: String, subtitle: String? = nil) {
let image = self.createImage(name: imageName, color: UIColor(Color.accentColor))
self.showError(title: title.key, image: image, subtitle: subtitle)
}
private func showError(title: String = "global.error.unexpected", image: UIImage?, subtitle: String? = nil) {
private func showError(title: String, image: UIImage?, subtitle: String? = nil) {
let drop = Drop(
title: NSLocalizedString(title, comment: "Error displayed to the user."),
subtitle: subtitle,

View File

@ -7,10 +7,6 @@
objects = {
/* Begin PBXBuildFile section */
F80048042961850500E6868A /* AttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048002961850500E6868A /* AttachmentData.swift */; };
F80048062961850500E6868A /* StatusData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048022961850500E6868A /* StatusData.swift */; };
F80048082961E6DE00E6868A /* StatusDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048072961E6DE00E6868A /* StatusDataHandler.swift */; };
F800480A2961EA1900E6868A /* AttachmentDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048092961EA1900E6868A /* AttachmentDataHandler.swift */; };
F802884F297AEED5000BDD51 /* DatabaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F802884E297AEED5000BDD51 /* DatabaseError.swift */; };
F805DCF129DBEF83006A1FD9 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F805DCF029DBEF83006A1FD9 /* ReportView.swift */; };
F808641429756666009F035C /* NotificationRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F808641329756666009F035C /* NotificationRowView.swift */; };
@ -34,8 +30,6 @@
F858906B29E1CC7A00D4BDED /* UIApplication+Window.swift in Sources */ = {isa = PBXBuildFile; fileRef = F858906A29E1CC7A00D4BDED /* UIApplication+Window.swift */; };
F85D0C652ABA08F9002B3577 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F85D0C642ABA08F9002B3577 /* Assets.xcassets */; };
F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D4970296402DC00751DF7 /* AuthorizationService.swift */; };
F85D4975296407F100751DF7 /* HomeTimelineService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D4974296407F100751DF7 /* HomeTimelineService.swift */; };
F85D497729640A5200751DF7 /* ImageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D497629640A5200751DF7 /* ImageRow.swift */; };
F85D497929640B9D00751DF7 /* ImagesCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D497829640B9D00751DF7 /* ImagesCarousel.swift */; };
F85D497D29640D5900751DF7 /* InteractionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D497C29640D5900751DF7 /* InteractionRow.swift */; };
F85D497F296416C800751DF7 /* CommentsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D497E296416C800751DF7 /* CommentsSectionView.swift */; };
@ -53,15 +47,11 @@
F864F77829BB930000B13921 /* PhotoWidgetEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F864F77729BB930000B13921 /* PhotoWidgetEntry.swift */; };
F864F77A29BB94A800B13921 /* PixelfedKit in Frameworks */ = {isa = PBXBuildFile; productRef = F864F77929BB94A800B13921 /* PixelfedKit */; };
F864F77C29BB982100B13921 /* StatusFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F864F77B29BB982100B13921 /* StatusFetcher.swift */; };
F864F77E29BB9A4900B13921 /* AttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048002961850500E6868A /* AttachmentData.swift */; };
F864F78329BB9A6800B13921 /* StatusData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048022961850500E6868A /* StatusData.swift */; };
F864F78529BB9A7100B13921 /* ApplicationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F69F296040A8002E8F88 /* ApplicationSettings.swift */; };
F864F78729BB9A7700B13921 /* AccountData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD29295F43B8009B20C9 /* AccountData.swift */; };
F864F78829BB9A7B00B13921 /* SwiftDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C2474295C37BB0006098B /* SwiftDataHandler.swift */; };
F864F78929BB9A7D00B13921 /* AccountDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A229604161002E8F88 /* AccountDataHandler.swift */; };
F864F78A29BB9A8000B13921 /* ApplicationSettingsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A429604194002E8F88 /* ApplicationSettingsHandler.swift */; };
F864F78B29BB9A8300B13921 /* StatusDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048072961E6DE00E6868A /* StatusDataHandler.swift */; };
F864F78C29BB9A8500B13921 /* AttachmentDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048092961EA1900E6868A /* AttachmentDataHandler.swift */; };
F864F7A529BBA01D00B13921 /* CoreDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F864F7A429BBA01D00B13921 /* CoreDataError.swift */; };
F864F7A629BBA01D00B13921 /* CoreDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F864F7A429BBA01D00B13921 /* CoreDataError.swift */; };
F866F6A1296040A8002E8F88 /* ApplicationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F69F296040A8002E8F88 /* ApplicationSettings.swift */; };
@ -114,15 +104,11 @@
F88BC53D29E06EAD00CE6141 /* ServicesKit in Frameworks */ = {isa = PBXBuildFile; productRef = F88BC53C29E06EAD00CE6141 /* ServicesKit */; };
F88BC53F29E06EB100CE6141 /* WidgetsKit in Frameworks */ = {isa = PBXBuildFile; productRef = F88BC53E29E06EB100CE6141 /* WidgetsKit */; };
F88BC54129E072A600CE6141 /* CoreDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F864F7A429BBA01D00B13921 /* CoreDataError.swift */; };
F88BC54229E072A900CE6141 /* AttachmentDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048092961EA1900E6868A /* AttachmentDataHandler.swift */; };
F88BC54329E072AC00CE6141 /* StatusDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048072961E6DE00E6868A /* StatusDataHandler.swift */; };
F88BC54429E072AF00CE6141 /* ApplicationSettingsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A429604194002E8F88 /* ApplicationSettingsHandler.swift */; };
F88BC54529E072B200CE6141 /* AccountDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A229604161002E8F88 /* AccountDataHandler.swift */; };
F88BC54629E072B500CE6141 /* SwiftDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C2474295C37BB0006098B /* SwiftDataHandler.swift */; };
F88BC54729E072B800CE6141 /* AccountData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD29295F43B8009B20C9 /* AccountData.swift */; };
F88BC54929E072C000CE6141 /* ApplicationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F69F296040A8002E8F88 /* ApplicationSettings.swift */; };
F88BC54B29E072CA00CE6141 /* StatusData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048022961850500E6868A /* StatusData.swift */; };
F88BC54D29E072D600CE6141 /* AttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048002961850500E6868A /* AttachmentData.swift */; };
F88BC55229E0798900CE6141 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F88BC55129E0798900CE6141 /* SharedAssets.xcassets */; };
F88BC55429E0798900CE6141 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F88BC55129E0798900CE6141 /* SharedAssets.xcassets */; };
F88C246C295C37B80006098B /* VernissageApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C246B295C37B80006098B /* VernissageApp.swift */; };
@ -136,10 +122,8 @@
F88E4D4A297EA0490057491A /* RouterPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88E4D49297EA0490057491A /* RouterPath.swift */; };
F88E4D4D297EA4290057491A /* EmojiText in Frameworks */ = {isa = PBXBuildFile; productRef = F88E4D4C297EA4290057491A /* EmojiText */; };
F88E4D56297EAD6E0057491A /* AppRouteur.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88E4D55297EAD6E0057491A /* AppRouteur.swift */; };
F88FAD21295F3944009B20C9 /* HomeFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD20295F3944009B20C9 /* HomeFeedView.swift */; };
F88FAD27295F400E009B20C9 /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD26295F400E009B20C9 /* NotificationsView.swift */; };
F88FAD2B295F43B8009B20C9 /* AccountData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD29295F43B8009B20C9 /* AccountData.swift */; };
F891E7CE29C35BF50022C449 /* ImageRowItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F891E7CD29C35BF50022C449 /* ImageRowItem.swift */; };
F891E7D029C368750022C449 /* ImageRowItemAsync.swift in Sources */ = {isa = PBXBuildFile; fileRef = F891E7CF29C368750022C449 /* ImageRowItemAsync.swift */; };
F89229EF2ADA63620040C964 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = F89229EE2ADA63620040C964 /* Localizable.xcstrings */; };
F89229F02ADA63620040C964 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = F89229EE2ADA63620040C964 /* Localizable.xcstrings */; };
@ -166,6 +150,8 @@
F8B0886029943498002AB40A /* OtherSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8B0885F29943498002AB40A /* OtherSectionView.swift */; };
F8B08862299435C9002AB40A /* SupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8B08861299435C9002AB40A /* SupportView.swift */; };
F8B758DE2AB9DD85000C8068 /* ColumnData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8B758DD2AB9DD85000C8068 /* ColumnData.swift */; };
F8D0E5222AE2A2630061C561 /* HomeTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D0E5212AE2A2630061C561 /* HomeTimelineView.swift */; };
F8D0E5242AE2A88A0061C561 /* HomeTimelineService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D0E5232AE2A88A0061C561 /* HomeTimelineService.swift */; };
F8D5444329D4066C002225D6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D5444229D4066C002225D6 /* AppDelegate.swift */; };
F8D8E0CB2ACC237000AA1374 /* ViewedStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D8E0CA2ACC237000AA1374 /* ViewedStatus.swift */; };
F8D8E0CC2ACC237000AA1374 /* ViewedStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D8E0CA2ACC237000AA1374 /* ViewedStatus.swift */; };
@ -225,10 +211,6 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
F80048002961850500E6868A /* AttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentData.swift; sourceTree = "<group>"; };
F80048022961850500E6868A /* StatusData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusData.swift; sourceTree = "<group>"; };
F80048072961E6DE00E6868A /* StatusDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusDataHandler.swift; sourceTree = "<group>"; };
F80048092961EA1900E6868A /* AttachmentDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDataHandler.swift; sourceTree = "<group>"; };
F802884E297AEED5000BDD51 /* DatabaseError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseError.swift; sourceTree = "<group>"; };
F805DCF029DBEF83006A1FD9 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; };
F808641329756666009F035C /* NotificationRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRowView.swift; sourceTree = "<group>"; };
@ -250,8 +232,6 @@
F858906A29E1CC7A00D4BDED /* UIApplication+Window.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIApplication+Window.swift"; sourceTree = "<group>"; };
F85D0C642ABA08F9002B3577 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
F85D4970296402DC00751DF7 /* AuthorizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationService.swift; sourceTree = "<group>"; };
F85D4974296407F100751DF7 /* HomeTimelineService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineService.swift; sourceTree = "<group>"; };
F85D497629640A5200751DF7 /* ImageRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRow.swift; sourceTree = "<group>"; };
F85D497829640B9D00751DF7 /* ImagesCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesCarousel.swift; sourceTree = "<group>"; };
F85D497C29640D5900751DF7 /* InteractionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionRow.swift; sourceTree = "<group>"; };
F85D497E296416C800751DF7 /* CommentsSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsSectionView.swift; sourceTree = "<group>"; };
@ -330,10 +310,8 @@
F88E4D47297E90CD0057491A /* TrendStatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendStatusesView.swift; sourceTree = "<group>"; };
F88E4D49297EA0490057491A /* RouterPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterPath.swift; sourceTree = "<group>"; };
F88E4D55297EAD6E0057491A /* AppRouteur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteur.swift; sourceTree = "<group>"; };
F88FAD20295F3944009B20C9 /* HomeFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFeedView.swift; sourceTree = "<group>"; };
F88FAD26295F400E009B20C9 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = "<group>"; };
F88FAD29295F43B8009B20C9 /* AccountData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountData.swift; sourceTree = "<group>"; };
F891E7CD29C35BF50022C449 /* ImageRowItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRowItem.swift; sourceTree = "<group>"; };
F891E7CF29C368750022C449 /* ImageRowItemAsync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRowItemAsync.swift; sourceTree = "<group>"; };
F89229EE2ADA63620040C964 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
F897978E29684BCB00B22335 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
@ -358,6 +336,8 @@
F8B3699A29D86EB600BE3808 /* .swiftlint.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = "<group>"; };
F8B3699B29D86EBD00BE3808 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = "<group>"; };
F8B758DD2AB9DD85000C8068 /* ColumnData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnData.swift; sourceTree = "<group>"; };
F8D0E5212AE2A2630061C561 /* HomeTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineView.swift; sourceTree = "<group>"; };
F8D0E5232AE2A88A0061C561 /* HomeTimelineService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineService.swift; sourceTree = "<group>"; };
F8D5444229D4066C002225D6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
F8D8E0CA2ACC237000AA1374 /* ViewedStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewedStatus.swift; sourceTree = "<group>"; };
F8D8E0CE2ACC23B300AA1374 /* ViewedStatusHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewedStatusHandler.swift; sourceTree = "<group>"; };
@ -470,7 +450,7 @@
F89D6C4829718868001DA3D4 /* StatusView */,
F88C246D295C37B80006098B /* MainView.swift */,
F88ABD9329687CA4004EF61E /* ComposeView.swift */,
F88FAD20295F3944009B20C9 /* HomeFeedView.swift */,
F8D0E5212AE2A2630061C561 /* HomeTimelineView.swift */,
F88AB05729B36B8200345EDE /* AccountsPhotoView.swift */,
F88E4D47297E90CD0057491A /* TrendStatusesView.swift */,
F883401F29B62AE900C3E096 /* SearchView.swift */,
@ -510,8 +490,6 @@
F8341F96295C6427009C8EE6 /* CoreData */ = {
isa = PBXGroup;
children = (
F80048002961850500E6868A /* AttachmentData.swift */,
F80048022961850500E6868A /* StatusData.swift */,
F866F69F296040A8002E8F88 /* ApplicationSettings.swift */,
F88FAD29295F43B8009B20C9 /* AccountData.swift */,
F8D8E0CA2ACC237000AA1374 /* ViewedStatus.swift */,
@ -519,8 +497,6 @@
F88C2474295C37BB0006098B /* SwiftDataHandler.swift */,
F866F6A229604161002E8F88 /* AccountDataHandler.swift */,
F866F6A429604194002E8F88 /* ApplicationSettingsHandler.swift */,
F80048072961E6DE00E6868A /* StatusDataHandler.swift */,
F80048092961EA1900E6868A /* AttachmentDataHandler.swift */,
F8D8E0CE2ACC23B300AA1374 /* ViewedStatusHandler.swift */,
F8E7AE012AD44D600038FFFD /* AccountRelationshipHandler.swift */,
F864F7A429BBA01D00B13921 /* CoreDataError.swift */,
@ -539,8 +515,6 @@
F83901A2295D863B00456AE2 /* Widgets */ = {
isa = PBXGroup;
children = (
F85D497629640A5200751DF7 /* ImageRow.swift */,
F891E7CD29C35BF50022C449 /* ImageRowItem.swift */,
F8210DCE2966B600001D9973 /* ImageRowAsync.swift */,
F891E7CF29C368750022C449 /* ImageRowItemAsync.swift */,
F85D497829640B9D00751DF7 /* ImagesCarousel.swift */,
@ -795,10 +769,10 @@
children = (
F85D4970296402DC00751DF7 /* AuthorizationService.swift */,
F87AEB912986C44E00434FB6 /* AuthorizationSession.swift */,
F85D4974296407F100751DF7 /* HomeTimelineService.swift */,
F88E4D49297EA0490057491A /* RouterPath.swift */,
F878842129A4A4E3003CFAD2 /* AppMetadataService.swift */,
F86BC9E829EBBB66009415EC /* ImageSaver.swift */,
F8D0E5232AE2A88A0061C561 /* HomeTimelineService.swift */,
);
path = Services;
sourceTree = "<group>";
@ -1054,8 +1028,6 @@
F8E7ADFF2AD44CEB0038FFFD /* AccountRelationship.swift in Sources */,
F8D8E0CC2ACC237000AA1374 /* ViewedStatus.swift in Sources */,
F864F7A629BBA01D00B13921 /* CoreDataError.swift in Sources */,
F864F77E29BB9A4900B13921 /* AttachmentData.swift in Sources */,
F864F78329BB9A6800B13921 /* StatusData.swift in Sources */,
F8FAA0AD2AB0BCB400FD78BD /* View+ContainerBackground.swift in Sources */,
F8705A7B29FF872F00DA818A /* QRCodeGenerator.swift in Sources */,
F8705A7E29FF880600DA818A /* FileFetcher.swift in Sources */,
@ -1069,10 +1041,8 @@
F864F78A29BB9A8000B13921 /* ApplicationSettingsHandler.swift in Sources */,
F84625F229FE2B6B002D3AF4 /* QRCodeWidgetEntry.swift in Sources */,
F84625EB29FE28D4002D3AF4 /* QRCodeWidgetEntryView.swift in Sources */,
F864F78C29BB9A8500B13921 /* AttachmentDataHandler.swift in Sources */,
F84625F829FE2C2F002D3AF4 /* AccountFetcher.swift in Sources */,
F84625F429FE2BF9002D3AF4 /* QRCodeProvider.swift in Sources */,
F864F78B29BB9A8300B13921 /* StatusDataHandler.swift in Sources */,
F8705A7929FF7CCB00DA818A /* QRCodeMediumWidgetView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1084,20 +1054,16 @@
F88BC54529E072B200CE6141 /* AccountDataHandler.swift in Sources */,
F88BC54729E072B800CE6141 /* AccountData.swift in Sources */,
F8D8E0D12ACC23B300AA1374 /* ViewedStatusHandler.swift in Sources */,
F88BC54D29E072D600CE6141 /* AttachmentData.swift in Sources */,
F8E7AE002AD44CEB0038FFFD /* AccountRelationship.swift in Sources */,
F88BC54629E072B500CE6141 /* SwiftDataHandler.swift in Sources */,
F88BC50529E02F3900CE6141 /* ShareViewController.swift in Sources */,
F88BC54129E072A600CE6141 /* CoreDataError.swift in Sources */,
F88BC54229E072A900CE6141 /* AttachmentDataHandler.swift in Sources */,
F88BC54429E072AF00CE6141 /* ApplicationSettingsHandler.swift in Sources */,
F8D8E0CD2ACC237000AA1374 /* ViewedStatus.swift in Sources */,
F88BC51629E0307F00CE6141 /* NotificationsName.swift in Sources */,
F8E7AE042AD44D600038FFFD /* AccountRelationshipHandler.swift in Sources */,
F88BC51329E02FD800CE6141 /* ComposeView.swift in Sources */,
F88BC54B29E072CA00CE6141 /* StatusData.swift in Sources */,
F88BC54929E072C000CE6141 /* ApplicationSettings.swift in Sources */,
F88BC54329E072AC00CE6141 /* StatusDataHandler.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1105,20 +1071,15 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F85D497729640A5200751DF7 /* ImageRow.swift in Sources */,
F88AB05529B3626300345EDE /* ImageGrid.swift in Sources */,
F87AEB922986C44E00434FB6 /* AuthorizationSession.swift in Sources */,
F86A4301299A97F500DF7645 /* ProductIdentifiers.swift in Sources */,
F89D6C4229717FDC001DA3D4 /* AccountsSectionView.swift in Sources */,
F8E36E482AB874A500769C55 /* StatusModel+Sizeable.swift in Sources */,
F80048082961E6DE00E6868A /* StatusDataHandler.swift in Sources */,
F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */,
F8B08862299435C9002AB40A /* SupportView.swift in Sources */,
F8B05ACB29B489B100857221 /* HapticsSectionView.swift in Sources */,
F88FAD2B295F43B8009B20C9 /* AccountData.swift in Sources */,
F85D4975296407F100751DF7 /* HomeTimelineService.swift in Sources */,
F80048062961850500E6868A /* StatusData.swift in Sources */,
F88FAD21295F3944009B20C9 /* HomeFeedView.swift in Sources */,
F88C2475295C37BB0006098B /* SwiftDataHandler.swift in Sources */,
F87AEB972986D16D00434FB6 /* AuthorisationError.swift in Sources */,
F8742FC429990AFB00E9642B /* ClientError.swift in Sources */,
@ -1137,14 +1098,12 @@
F88E4D42297E69FD0057491A /* StatusesView.swift in Sources */,
F878842229A4A4E3003CFAD2 /* AppMetadataService.swift in Sources */,
F85D497929640B9D00751DF7 /* ImagesCarousel.swift in Sources */,
F8D0E5222AE2A2630061C561 /* HomeTimelineView.swift in Sources */,
F89AC00529A1F9B500F4159F /* AppMetadata.swift in Sources */,
F891E7CE29C35BF50022C449 /* ImageRowItem.swift in Sources */,
F86B7221296C49A300EE59EC /* EmptyButtonStyle.swift in Sources */,
F80048042961850500E6868A /* AttachmentData.swift in Sources */,
F8B758DE2AB9DD85000C8068 /* ColumnData.swift in Sources */,
F88E4D4A297EA0490057491A /* RouterPath.swift in Sources */,
F88E4D48297E90CD0057491A /* TrendStatusesView.swift in Sources */,
F800480A2961EA1900E6868A /* AttachmentDataHandler.swift in Sources */,
F891E7D029C368750022C449 /* ImageRowItemAsync.swift in Sources */,
F89D6C4A297196FF001DA3D4 /* ImageViewer.swift in Sources */,
F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */,
@ -1179,6 +1138,7 @@
F89B5CC229D01BF700549F2F /* InstanceView.swift in Sources */,
F825F0CB29F7CFC4008BD204 /* FollowRequestsView.swift in Sources */,
F825F0C929F7A562008BD204 /* UserProfilePrivateAccountView.swift in Sources */,
F8D0E5242AE2A88A0061C561 /* HomeTimelineService.swift in Sources */,
F89F57B029D1C11200001EE3 /* RelationshipModel.swift in Sources */,
F88AB05829B36B8200345EDE /* AccountsPhotoView.swift in Sources */,
F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */,

View File

@ -19,88 +19,11 @@ import Semaphore
public class HomeTimelineService {
public static let shared = HomeTimelineService()
private init() { }
private let defaultAmountOfDownloadedStatuses = 40
private let maximumAmountOfDownloadedStatuses = 80
private let imagePrefetcher = ImagePrefetcher(destination: .diskCache)
private let semaphore = AsyncSemaphore(value: 1)
public func loadOnBottom(for account: AccountModel, includeReblogs: Bool, hideStatusesWithoutAlt: Bool, modelContext: ModelContext) async throws -> Int {
// Get minimum downloaded stauts id.
let oldestStatus = StatusDataHandler.shared.getMinimumStatus(accountId: account.id, modelContext: modelContext)
guard let oldestStatus = oldestStatus else {
return 0
}
// Load data on bottom of the list.
let allStatusesFromApi = try await self.load(for: account,
includeReblogs: includeReblogs,
hideStatusesWithoutAlt: hideStatusesWithoutAlt,
modelContext: modelContext,
maxId: oldestStatus.id)
// Save data into database.
try modelContext.save()
// Start prefetching images.
self.prefetch(statuses: allStatusesFromApi)
// Return amount of newly downloaded statuses.
return allStatusesFromApi.count
}
public func refreshTimeline(for account: AccountModel,
includeReblogs: Bool,
hideStatusesWithoutAlt: Bool,
updateLastSeenStatus: Bool = false,
modelContext: ModelContext) async throws -> String? {
await semaphore.wait()
defer { semaphore.signal() }
// Retrieve newest visible status (last visible by user).
let dbNewestStatus = StatusDataHandler.shared.getMaximumStatus(accountId: account.id, modelContext: modelContext)
let lastSeenStatusId = dbNewestStatus?.id
// Refresh/load home timeline (refreshing on top downloads always first 40 items).
// When Apple introduce good way to show new items without scroll to top then we can change that method.
let allStatusesFromApi = try await self.refresh(for: account,
includeReblogs: includeReblogs,
hideStatusesWithoutAlt: hideStatusesWithoutAlt,
modelContext: modelContext)
// Update last seen status.
if let lastSeenStatusId, updateLastSeenStatus == true {
try self.update(lastSeenStatusId: lastSeenStatusId, for: account, modelContext: modelContext)
}
// Delete old viewed statuses from database.
ViewedStatusHandler.shared.deleteOldViewedStatuses(modelContext: modelContext)
// Start prefetching images.
self.prefetch(statuses: allStatusesFromApi)
// Save data into database.
try modelContext.save()
// Return id of last seen status.
return lastSeenStatusId
}
public func update(attachment: AttachmentData, withData imageData: Data, imageWidth: Double, imageHeight: Double, modelContext: ModelContext) {
attachment.data = imageData
attachment.metaImageWidth = Int32(imageWidth)
attachment.metaImageHeight = Int32(imageHeight)
// TODO: Uncomment/remove when exif metadata will be supported.
// self.setExifProperties(in: attachment, from: imageData)
// Save data into database.
try? modelContext.save()
}
public func amountOfNewStatuses(for account: AccountModel, includeReblogs: Bool, hideStatusesWithoutAlt: Bool, modelContext: ModelContext) async -> Int {
await semaphore.wait()
defer { semaphore.signal() }
@ -108,59 +31,33 @@ public class HomeTimelineService {
guard let accessToken = account.accessToken else {
return 0
}
// Get maximimum downloaded stauts id.
let newestStatus = StatusDataHandler.shared.getMaximumStatus(accountId: account.id, modelContext: modelContext)
guard let newestStatus else {
guard let lastSeenStatusId = self.getLastLoadedStatusId(accountId: account.id, modelContext: modelContext) else {
return 0
}
let client = PixelfedClient(baseURL: account.serverUrl).getAuthenticated(token: accessToken)
var statuses: [Status] = []
var newestStatusId = newestStatus.id
var newestStatusId = lastSeenStatusId
// There can be more then 80 newest statuses, that's why we have to sometimes send more then one request.
while true {
do {
let downloadedStatuses = try await client.getHomeTimeline(minId: newestStatusId,
limit: self.maximumAmountOfDownloadedStatuses,
includeReblogs: includeReblogs)
guard let firstStatus = downloadedStatuses.first else {
break
}
let visibleStatuses = self.getVisibleStatuses(accountId: account.id,
statuses: downloadedStatuses,
hideStatusesWithoutAlt: hideStatusesWithoutAlt,
modelContext: modelContext)
// We have to include in the counter only statuses with images.
let statusesWithImagesOnly = downloadedStatuses.getStatusesWithImagesOnly()
for status in statusesWithImagesOnly {
// We have to hide statuses without ALT text.
if hideStatusesWithoutAlt && status.statusContainsAltText() == false {
continue
}
// We shouldn't add statuses that are boosted by muted accounts.
if AccountRelationshipHandler.shared.isBoostedStatusesMuted(accountId: account.id, status: status, modelContext: modelContext) {
continue
}
// We should add to timeline only statuses that has not been showned to the user already.
guard self.hasBeenAlreadyOnTimeline(accountId: account.id, status: status, modelContext: modelContext) == false else {
continue
}
// Same rebloged status has been already visible in current portion of data.
if let reblog = status.reblog, statuses.contains(where: { $0.reblog?.id == reblog.id }) {
continue
}
// Same status has been already visible in current portion of data.
if let reblog = status.reblog, statusesWithImagesOnly.contains(where: { $0.id == reblog.id }) {
continue
}
statuses.append(status)
}
statuses.append(contentsOf: visibleStatuses)
newestStatusId = firstStatus.id
} catch {
@ -171,256 +68,58 @@ public class HomeTimelineService {
// Start prefetching images.
self.prefetch(statuses: statuses)
// Return number of new statuses not visible yet on the timeline.
return statuses.count
}
private func update(lastSeenStatusId: String, for account: AccountModel, modelContext: ModelContext) throws {
// Save information about last seen status.
guard let accountDataFromDb = AccountDataHandler.shared.getAccountData(accountId: account.id, modelContext: modelContext) else {
throw DatabaseError.cannotDownloadAccount
}
accountDataFromDb.lastSeenStatusId = lastSeenStatusId
}
private func update(status statusData: StatusData, basedOn status: Status, for account: AccountModel, modelContext: ModelContext) async throws -> StatusData? {
// Update status data in database.
self.copy(from: status, to: statusData, modelContext: modelContext)
// Save data into database.
try modelContext.save()
return statusData
}
private func refresh(for account: AccountModel, includeReblogs: Bool, hideStatusesWithoutAlt: Bool, modelContext: ModelContext) async throws -> [Status] {
// Retrieve statuses from API.
let statuses = try await self.getUniqueStatusesForHomeTimeline(account: account,
includeReblogs: includeReblogs,
hideStatusesWithoutAlt: hideStatusesWithoutAlt,
modelContext: modelContext)
// Update all existing statuses in database.
for status in statuses {
if let dbStatus = StatusDataHandler.shared.getStatusData(accountId: account.id, statusId: status.id, modelContext: modelContext) {
dbStatus.updateFrom(status)
}
}
// Add statuses which are not existing in database, but has been downloaded via API.
var statusesToAdd: [Status] = []
for status in statuses where StatusDataHandler.shared.getStatusData(accountId: account.id,
statusId: status.id,
modelContext: modelContext) == nil {
statusesToAdd.append(status)
}
// Collection with statuses to remove from database.
var dbStatusesToRemove: [StatusData] = []
let allDbStatuses = StatusDataHandler.shared.getAllStatuses(accountId: account.id, modelContext: modelContext)
// Find statuses to delete (not exiting in the API results).
for dbStatus in allDbStatuses where !statuses.contains(where: { status in status.id == dbStatus.id }) {
dbStatusesToRemove.append(dbStatus)
}
// Find statuses to delete (duplicates).
var existingStatusIds: [String] = []
for dbStatus in allDbStatuses {
if existingStatusIds.contains(where: { $0 == dbStatus.id }) {
dbStatusesToRemove.append(dbStatus)
} else {
existingStatusIds.append(dbStatus.id)
}
}
// Delete statuses from database.
if !dbStatusesToRemove.isEmpty {
for dbStatusToRemove in dbStatusesToRemove {
modelContext.delete(dbStatusToRemove)
}
}
// Save statuses in database.
if !statusesToAdd.isEmpty {
_ = try await self.add(statusesToAdd, for: account, modelContext: modelContext)
}
// Return all statuses downloaded from API.
return statuses
}
private func load(for account: AccountModel,
includeReblogs: Bool,
hideStatusesWithoutAlt: Bool,
modelContext: ModelContext,
maxId: String? = nil
) async throws -> [Status] {
// Retrieve statuses from API.
let statuses = try await self.getUniqueStatusesForHomeTimeline(account: account,
maxId: maxId,
includeReblogs: includeReblogs,
hideStatusesWithoutAlt: hideStatusesWithoutAlt,
modelContext: modelContext)
// Save statuses in database.
try await self.add(statuses, for: account, modelContext: modelContext)
// Return all statuses downloaded from API.
return statuses
}
private func add(_ statuses: [Status], for account: AccountModel, modelContext: ModelContext) async throws {
guard let accountDataFromDb = AccountDataHandler.shared.getAccountData(accountId: account.id, modelContext: modelContext) else {
throw DatabaseError.cannotDownloadAccount
}
// Proceed statuses with images only.
let statusesWithImages = statuses.getStatusesWithImagesOnly()
// Save all data to database.
for status in statusesWithImages {
// Save status to database.
let statusData = StatusData()
self.copy(from: status, to: statusData, modelContext: modelContext)
accountDataFromDb.statuses.append(statusData)
statusData.pixelfedAccount = accountDataFromDb
modelContext.insert(statusData)
public func getVisibleStatuses(accountId: String, statuses: [Status], hideStatusesWithoutAlt: Bool, modelContext: ModelContext) -> [Status] {
// We have to include in the counter only statuses with images.
let statusesWithImagesOnly = statuses.getStatusesWithImagesOnly()
var visibleStatuses: [Status] = []
for status in statusesWithImagesOnly {
// Save statusId to viewed statuses.
let viewedStatus = ViewedStatus(id: status.id, reblogId: status.reblog?.id, date: Date(), pixelfedAccount: accountDataFromDb)
accountDataFromDb.viewedStatuses.append(viewedStatus)
modelContext.insert(viewedStatus)
// We have to hide statuses without ALT text.
if hideStatusesWithoutAlt && status.statusContainsAltText() == false {
continue
}
// We shouldn't add statuses that are boosted by muted accounts.
if AccountRelationshipHandler.shared.isBoostedStatusesMuted(accountId: accountId, status: status, modelContext: modelContext) {
continue
}
// We should add to timeline only statuses that has not been showned to the user already.
guard self.hasBeenAlreadyOnTimeline(accountId: accountId, status: status, modelContext: modelContext) == false else {
continue
}
// Same rebloged status has been already visible in current portion of data.
if let reblog = status.reblog, visibleStatuses.contains(where: { $0.reblog?.id == reblog.id || $0.id == reblog.id }) {
continue
}
visibleStatuses.append(status)
}
print("statuses: \(statuses.count), withImages: \(statusesWithImagesOnly.count), visible: \(visibleStatuses.count)")
return visibleStatuses
}
private func copy(from status: Status, to statusData: StatusData, modelContext: ModelContext) {
statusData.copyFrom(status)
for (index, attachment) in status.getAllImageMediaAttachments().enumerated() {
// Save attachment in database.
if let attachmentData = statusData.attachments().first(where: { item in item.id == attachment.id }) {
attachmentData.copyFrom(attachment)
attachmentData.statusId = statusData.id
attachmentData.order = Int32(index)
} else {
let attachmentData = AttachmentData(id: attachment.id, statusId: statusData.id, url: attachment.url)
attachmentData.copyFrom(attachment)
attachmentData.statusId = statusData.id
attachmentData.order = Int32(index)
attachmentData.statusRelation = statusData
statusData.attachmentsRelation.append(attachmentData)
modelContext.insert(attachmentData)
}
}
private func hasBeenAlreadyOnTimeline(accountId: String, status: Status, modelContext: ModelContext) -> Bool {
return ViewedStatusHandler.shared.hasBeenAlreadyOnTimeline(accountId: accountId, status: status, modelContext: modelContext)
}
private func getLastLoadedStatusId(accountId: String, modelContext: ModelContext) -> String? {
let accountData = AccountDataHandler.shared.getAccountData(accountId: accountId, modelContext: modelContext)
return accountData?.lastLoadedStatusId
}
private func setExifProperties(in attachmentData: AttachmentData, from imageData: Data) {
// Read exif information.
if let exifProperties = imageData.getExifData() {
if let make = exifProperties.getExifValue("Make"), let model = exifProperties.getExifValue("Model") {
attachmentData.exifCamera = "\(make) \(model)"
}
// "Lens" or "Lens Model"
if let lens = exifProperties.getExifValue("Lens") {
attachmentData.exifLens = lens
}
if let createData = exifProperties.getExifValue("CreateDate") {
attachmentData.exifCreatedDate = createData
}
if let focalLenIn35mmFilm = exifProperties.getExifValue("FocalLenIn35mmFilm"),
let fNumber = exifProperties.getExifValue("FNumber")?.calculateExifNumber(),
let exposureTime = exifProperties.getExifValue("ExposureTime"),
let photographicSensitivity = exifProperties.getExifValue("PhotographicSensitivity") {
attachmentData.exifExposure = "\(focalLenIn35mmFilm)mm, f/\(fNumber), \(exposureTime)s, ISO \(photographicSensitivity)"
}
}
}
private func prefetch(statuses: [Status]) {
let statusModels = statuses.toStatusModels()
imagePrefetcher.startPrefetching(with: statusModels.getAllImagesUrls())
}
private func hasBeenAlreadyOnTimeline(accountId: String, status: Status, modelContext: ModelContext) -> Bool {
return ViewedStatusHandler.shared.hasBeenAlreadyOnTimeline(accountId: accountId, status: status, modelContext: modelContext)
}
private func getUniqueStatusesForHomeTimeline(account: AccountModel,
maxId: EntityId? = nil,
includeReblogs: Bool? = nil,
hideStatusesWithoutAlt: Bool = false,
modelContext: ModelContext) async throws -> [Status] {
guard let accessToken = account.accessToken else {
return []
}
let client = PixelfedClient(baseURL: account.serverUrl).getAuthenticated(token: accessToken)
var lastStatusId = maxId
var statuses: [Status] = []
while true {
let downloadedStatuses = try await client.getHomeTimeline(maxId: lastStatusId,
limit: self.maximumAmountOfDownloadedStatuses,
includeReblogs: includeReblogs)
// When there is not any older statuses we have to finish.
guard let lastStatus = downloadedStatuses.last else {
break
}
// We have to include in the counter only statuses with images.
let statusesWithImagesOnly = downloadedStatuses.getStatusesWithImagesOnly()
for status in statusesWithImagesOnly {
// When we process default amount of statuses to show we can stop adding another ones to the list.
if statuses.count == self.defaultAmountOfDownloadedStatuses {
break
}
// We have to hide statuses without ALT text.
if hideStatusesWithoutAlt && status.statusContainsAltText() == false {
continue
}
// We shouldn't add statuses that are boosted by muted accounts.
if AccountRelationshipHandler.shared.isBoostedStatusesMuted(accountId: account.id, status: status, modelContext: modelContext) {
continue
}
// We should add to timeline only statuses that has not been showned to the user already.
guard self.hasBeenAlreadyOnTimeline(accountId: account.id, status: status, modelContext: modelContext) == false else {
continue
}
// Same rebloged status has been already visible in current portion of data.
if let reblog = status.reblog, statuses.contains(where: { $0.reblog?.id == reblog.id }) {
continue
}
// Same status has been already visible in current portion of data.
if let reblog = status.reblog, statusesWithImagesOnly.contains(where: { $0.id == reblog.id }) {
continue
}
statuses.append(status)
}
if statuses.count >= self.defaultAmountOfDownloadedStatuses {
break
}
lastStatusId = lastStatus.id
}
return statuses
}
}

View File

@ -20,17 +20,6 @@ public extension View {
)
)
}
func imageContextMenu(statusData: StatusData, attachmentData: AttachmentData, uiImage: UIImage?) -> some View {
modifier(
ImageContextMenu(
id: statusData.getOrginalStatusId(),
url: statusData.url,
altText: attachmentData.text,
uiImage: uiImage
)
)
}
}
private struct ImageContextMenu: ViewModifier {

View File

@ -1,228 +0,0 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import SwiftUI
import ServicesKit
import EnvironmentKit
import WidgetsKit
import OSLog
import Semaphore
import SwiftData
@MainActor
struct HomeFeedView: View {
@Environment(\.modelContext) private var modelContext
@Environment(ApplicationState.self) var applicationState
@Environment(RouterPath.self) var routerPath
@State private var allItemsLoaded = false
@State private var state: ViewState = .loading
@State private var opacity = 0.0
@State private var offset = -50.0
@Query var dbStatuses: [StatusData]
init(accountId: String) {
_dbStatuses = Query(filter: #Predicate<StatusData> { statusData in
statusData.pixelfedAccount?.id == accountId
}, sort: [SortDescriptor(\.id, order: .reverse)])
}
var body: some View {
switch state {
case .loading:
LoadingIndicator()
.task {
await self.loadData()
}
case .loaded:
if self.dbStatuses.isEmpty {
NoDataView(imageSystemName: "photo.on.rectangle.angled", text: "home.title.noPhotos")
} else {
self.timeline()
}
case .error(let error):
ErrorView(error: error) {
self.state = .loading
await self.loadData()
}
.padding()
}
}
@ViewBuilder
private func timeline() -> some View {
ZStack {
ScrollView {
LazyVStack {
ForEach(dbStatuses, id: \.self) { item in
if self.shouldUpToDateBeVisible(statusId: item.id) {
self.upToDatePlaceholder()
}
ImageRow(statusData: item)
}
if allItemsLoaded == false {
LoadingIndicator()
.task {
do {
if let account = self.applicationState.account {
let newStatusesCount = try await HomeTimelineService.shared.loadOnBottom(
for: account,
includeReblogs: self.applicationState.showReboostedStatuses,
hideStatusesWithoutAlt: self.applicationState.hideStatusesWithoutAlt,
modelContext: modelContext
)
if newStatusesCount == 0 {
allItemsLoaded = true
}
}
} catch {
ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadStatuses", showToastr: !Task.isCancelled)
}
}
}
}
}
self.newPhotosView()
.offset(y: self.offset)
.opacity(self.opacity)
}
.refreshable {
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
await self.refreshData()
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
}
.onChange(of: self.applicationState.amountOfNewStatuses) {
self.calculateOffset()
}.onAppear {
self.calculateOffset()
}
}
private func refreshData() async {
do {
if let account = self.applicationState.account {
let lastSeenStatusId = try await HomeTimelineService.shared.refreshTimeline(
for: account,
includeReblogs: self.applicationState.showReboostedStatuses,
hideStatusesWithoutAlt: self.applicationState.hideStatusesWithoutAlt,
updateLastSeenStatus: true,
modelContext: modelContext)
asyncAfter(0.75) {
self.applicationState.lastSeenStatusId = lastSeenStatusId
self.applicationState.amountOfNewStatuses = 0
}
}
} catch {
ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadStatuses", showToastr: !Task.isCancelled)
}
}
private func loadData() async {
do {
// We have to load data automatically only when the database is empty.
guard self.dbStatuses.isEmpty else {
withAnimation {
self.state = .loaded
}
return
}
if let account = self.applicationState.account {
_ = try await HomeTimelineService.shared.refreshTimeline(
for: account,
includeReblogs: self.applicationState.showReboostedStatuses,
hideStatusesWithoutAlt: self.applicationState.hideStatusesWithoutAlt,
modelContext: modelContext)
}
self.applicationState.amountOfNewStatuses = 0
self.state = .loaded
} catch {
if !Task.isCancelled {
ErrorService.shared.handle(error, message: "global.error.statusesNotRetrieved", showToastr: true)
self.state = .error(error)
} else {
ErrorService.shared.handle(error, message: "global.error.statusesNotRetrieved", showToastr: false)
}
}
}
private func calculateOffset() {
if self.applicationState.amountOfNewStatuses > 0 {
withAnimation(.easeIn) {
self.showNewStatusesView()
}
} else {
withAnimation(.easeOut) {
self.hideNewStatusesView()
}
}
}
private func showNewStatusesView() {
self.offset = 0.0
self.opacity = 1.0
}
private func hideNewStatusesView() {
self.offset = -50.0
self.opacity = 0.0
}
private func shouldUpToDateBeVisible(statusId: String) -> Bool {
return self.applicationState.lastSeenStatusId != dbStatuses.first?.id && self.applicationState.lastSeenStatusId == statusId
}
@ViewBuilder
private func upToDatePlaceholder() -> some View {
VStack(alignment: .center) {
Image(systemName: "checkmark.seal")
.resizable()
.frame(width: 64, height: 64)
.fontWeight(.ultraLight)
.foregroundColor(self.applicationState.tintColor.color().opacity(0.6))
Text("home.title.allCaughtUp", comment: "You're all caught up")
.font(.title2)
.fontWeight(.thin)
.foregroundColor(Color.mainTextColor.opacity(0.6))
}
.padding(.vertical, 8)
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width * 0.75)
}
@ViewBuilder
private func newPhotosView() -> some View {
VStack(alignment: .trailing, spacing: 4) {
HStack {
Spacer()
HStack {
Image(systemName: "arrow.up")
.fontWeight(.light)
Text("\(self.applicationState.amountOfNewStatuses)")
.fontWeight(.semibold)
}
.padding(.vertical, 12)
.padding(.horizontal, 18)
.font(.callout)
.foregroundColor(Color.mainTextColor)
.background(.ultraThinMaterial)
.clipShape(Capsule())
}
Spacer()
}
.padding(.top, 10)
.padding(.trailing, 6)
}
}

View File

@ -0,0 +1,328 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import SwiftUI
import Nuke
import PixelfedKit
import ClientKit
import ServicesKit
import EnvironmentKit
import WidgetsKit
@MainActor
struct HomeTimelineView: View {
@Environment(ApplicationState.self) var applicationState
@Environment(Client.self) var client
@Environment(\.modelContext) private var modelContext
@State private var allItemsLoaded = false
@State private var statusViewModels: [StatusModel] = []
@State private var state: ViewState = .loading
@State private var lastStatusId: String?
@State private var opacity = 0.0
@State private var offset = -50.0
private let defaultLimit = 80
private let imagePrefetcher = ImagePrefetcher(destination: .diskCache)
var body: some View {
switch state {
case .loading:
LoadingIndicator()
.task {
await self.loadData()
}
case .loaded:
if self.statusViewModels.isEmpty {
NoDataView(imageSystemName: "photo.on.rectangle.angled", text: "statuses.title.noPhotos")
} else {
self.list()
}
case .error(let error):
ErrorView(error: error) {
self.state = .loading
await self.loadData()
}
.padding()
}
}
@ViewBuilder
private func list() -> some View {
ZStack {
ScrollView {
LazyVStack(alignment: .center) {
ForEach(self.statusViewModels, id: \.id) { item in
if self.shouldUpToDateBeVisible(statusId: item.id) {
self.upToDatePlaceholder()
}
ImageRowAsync(statusViewModel: item, containerWidth: Binding.constant(UIScreen.main.bounds.width))
}
if allItemsLoaded == false {
HStack {
Spacer()
LoadingIndicator()
.task {
do {
try await self.loadMoreStatuses()
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
}
}
Spacer()
}
}
}
}
self.newPhotosView()
.offset(y: self.offset)
.opacity(self.opacity)
}
.refreshable {
do {
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
try await self.refreshStatuses()
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
}
}
.onChange(of: self.applicationState.showReboostedStatuses) {
Task {
do {
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
try await self.refreshStatuses()
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
}
}
}
.onChange(of: self.applicationState.amountOfNewStatuses) {
self.calculateOffset()
}.onAppear {
self.calculateOffset()
}
}
@ViewBuilder
private func upToDatePlaceholder() -> some View {
VStack(alignment: .center) {
Image(systemName: "checkmark.seal")
.resizable()
.frame(width: 64, height: 64)
.fontWeight(.ultraLight)
.foregroundColor(self.applicationState.tintColor.color().opacity(0.6))
Text("home.title.allCaughtUp", comment: "You're all caught up")
.font(.title2)
.fontWeight(.thin)
.foregroundColor(Color.mainTextColor.opacity(0.6))
}
.padding(.vertical, 8)
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width * 0.75)
}
@ViewBuilder
private func newPhotosView() -> some View {
VStack(alignment: .trailing, spacing: 4) {
HStack {
Spacer()
HStack {
Image(systemName: "arrow.up")
.fontWeight(.light)
Text("\(self.applicationState.amountOfNewStatuses)")
.fontWeight(.semibold)
}
.padding(.vertical, 12)
.padding(.horizontal, 18)
.font(.callout)
.foregroundColor(Color.mainTextColor)
.background(.ultraThinMaterial)
.clipShape(Capsule())
}
Spacer()
}
.padding(.top, 10)
.padding(.trailing, 6)
}
private func loadData() async {
do {
try await self.loadFirstStatuses()
try ViewedStatusHandler.shared.deleteOldViewedStatuses(modelContext: modelContext)
self.state = .loaded
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
self.state = .error(error)
}
}
private func loadFirstStatuses() async throws {
guard let accountId = self.applicationState.account?.id else {
return
}
// Download statuses from API.
let statuses = try await self.loadFromApi()
if statuses.isEmpty {
self.allItemsLoaded = true
return
}
// Remember last status id returned by API.
self.lastStatusId = statuses.last?.id
// Get only visible statuses.
let visibleStatuses = HomeTimelineService.shared.getVisibleStatuses(accountId: accountId,
statuses: statuses,
hideStatusesWithoutAlt: self.applicationState.hideStatusesWithoutAlt,
modelContext: modelContext)
// Remeber first status returned by API in user context (when it's newer then remembered).
try AccountDataHandler.shared.update(lastSeenStatusId: nil, lastLoadedStatusId: statuses.first?.id, accountId: accountId, modelContext: modelContext)
// Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)
// Map to view models.
let statusModels = visibleStatuses.map({ StatusModel(status: $0) })
// Prefetch images.
self.prefetch(statusModels: statusModels)
// Append to empty list.
self.statusViewModels.append(contentsOf: statusModels)
}
private func loadMoreStatuses() async throws {
if let lastStatusId = self.lastStatusId, let accountId = self.applicationState.account?.id {
// Download statuses from API.
let statuses = try await self.loadFromApi(maxId: lastStatusId)
if statuses.isEmpty {
self.allItemsLoaded = true
return
}
// Now we have new last status.
if let lastStatusId = statuses.last?.id {
self.lastStatusId = lastStatusId
}
// Get only visible statuses.
let visibleStatuses = HomeTimelineService.shared.getVisibleStatuses(accountId: accountId,
statuses: statuses,
hideStatusesWithoutAlt: self.applicationState.hideStatusesWithoutAlt,
modelContext: modelContext)
// Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)
// Map to view models.
let statusModels = visibleStatuses.map({ StatusModel(status: $0) })
// Prefetch images.
self.prefetch(statusModels: statusModels)
// Append statuses to existing array of statuses (at the end).
self.statusViewModels.append(contentsOf: statusModels)
}
}
private func refreshStatuses() async throws {
guard let accountId = self.applicationState.account?.id else {
return
}
// Download statuses from API.
let statuses = try await self.loadFromApi()
if statuses.isEmpty {
self.allItemsLoaded = true
return
}
// Remember last status id returned by API.
self.lastStatusId = statuses.last?.id
// Get only visible statuses.
let visibleStatuses = HomeTimelineService.shared.getVisibleStatuses(accountId: accountId,
statuses: statuses,
hideStatusesWithoutAlt: self.applicationState.hideStatusesWithoutAlt,
modelContext: modelContext)
// Remeber first status returned by API in user context (when it's newer then remembered).
try AccountDataHandler.shared.update(lastSeenStatusId: self.statusViewModels.first?.id, lastLoadedStatusId: statuses.first?.id, accountId: accountId, modelContext: modelContext)
// Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)
// Map to view models.
let statusModels = visibleStatuses.map({ StatusModel(status: $0) })
// Prefetch images.
self.prefetch(statusModels: statusModels)
// Replace old collection with new one.
self.statusViewModels = statusModels
}
private func loadFromApi(maxId: String? = nil, sinceId: String? = nil, minId: String? = nil) async throws -> [Status] {
return try await self.client.publicTimeline?.getHomeTimeline(
maxId: maxId,
sinceId: sinceId,
minId: minId,
limit: self.defaultLimit,
includeReblogs: self.applicationState.showReboostedStatuses) ?? []
}
private func calculateOffset() {
if self.applicationState.amountOfNewStatuses > 0 {
withAnimation(.easeIn) {
self.showNewStatusesView()
}
} else {
withAnimation(.easeOut) {
self.hideNewStatusesView()
}
}
}
private func showNewStatusesView() {
self.offset = 0.0
self.opacity = 1.0
}
private func hideNewStatusesView() {
self.offset = -50.0
self.opacity = 0.0
}
private func prefetch(statusModels: [StatusModel]) {
imagePrefetcher.startPrefetching(with: statusModels.getAllImagesUrls())
}
private func shouldHideStatusWithoutAlt(status: Status) -> Bool {
if self.applicationState.hideStatusesWithoutAlt == false {
return false
}
return status.statusContainsAltText() == false
}
private func shouldUpToDateBeVisible(statusId: String) -> Bool {
return self.applicationState.lastSeenStatusId != statusViewModels.first?.id && self.applicationState.lastSeenStatusId == statusId
}
}

View File

@ -123,7 +123,7 @@ struct MainView: View {
switch self.viewMode {
case .home:
if UIDevice.isIPhone {
HomeFeedView(accountId: applicationState.account?.id ?? String.empty())
HomeTimelineView()
.id(applicationState.account?.id ?? String.empty())
} else {
StatusesView(listType: .home)
@ -292,7 +292,7 @@ struct MainView: View {
accountModel: accountModel,
modelContext: modelContext) { signedInAccountModel in
guard let signedInAccountModel else {
ToastrService.shared.showError(subtitle: NSLocalizedString("mainview.error.switchAccounts", comment: "Cannot switch accounts."))
ToastrService.shared.showError(title: "", subtitle: NSLocalizedString("mainview.error.switchAccounts", comment: "Cannot switch accounts."))
return
}

View File

@ -215,7 +215,6 @@ struct StatusView: View {
self.state = .loaded
} catch NetworkError.notSuccessResponse(let response) {
if response.statusCode() == HTTPStatusCode.notFound, let accountId = self.applicationState.account?.id {
StatusDataHandler.shared.remove(accountId: accountId, statusId: self.statusId, modelContext: modelContext)
ErrorService.shared.handle(NetworkError.notSuccessResponse(response), message: "status.error.notFound", showToastr: true)
self.dismiss()
}
@ -229,13 +228,6 @@ struct StatusView: View {
}
}
private func setAttachment(_ attachmentData: AttachmentData) {
exifCamera = attachmentData.exifCamera
exifExposure = attachmentData.exifExposure
exifCreatedDate = attachmentData.exifCreatedDate
exifLens = attachmentData.exifLens
}
private func getImageHeight() -> Double {
if let highestImageUrl = self.highestImageUrl, let imageSize = ImageSizeService.shared.get(for: highestImageUrl) {
let calculatedSize = ImageSizeService.shared.calculate(width: imageSize.width, height: imageSize.height)

View File

@ -61,7 +61,7 @@ struct StatusesView: View {
@State private var containerWidth: Double = UIDevice.isIPad ? UIScreen.main.bounds.width / 3 : UIScreen.main.bounds.width
@State private var containerHeight: Double = UIDevice.isIPad ? UIScreen.main.bounds.height / 3 : UIScreen.main.bounds.height
private let defaultLimit = 40
private let defaultLimit = 80
private let imagePrefetcher = ImagePrefetcher(destination: .diskCache)
var body: some View {
@ -139,7 +139,7 @@ struct StatusesView: View {
.refreshable {
do {
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
try await self.loadTopStatuses()
try await self.refreshStatuses()
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
} catch {
ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled)
@ -152,7 +152,7 @@ struct StatusesView: View {
Task { @MainActor in
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
try await self.loadTopStatuses()
try await self.refreshStatuses()
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
}
}
@ -174,6 +174,10 @@ struct StatusesView: View {
}
private func loadStatuses() async throws {
guard let accountId = self.applicationState.account?.id else {
return
}
let statuses = try await self.loadFromApi()
if statuses.isEmpty {
@ -184,68 +188,71 @@ struct StatusesView: View {
// Remember last status id returned by API.
self.lastStatusId = statuses.last?.id
// Get only statuses with images.
var inPlaceStatuses: [StatusModel] = []
for item in statuses.getStatusesWithImagesOnly() {
// We have to hide statuses without ALT text.
if self.shouldHideStatusWithoutAlt(status: item) {
continue
}
// We have to skip statuses that are boosted from muted accounts.
if let accountId = self.applicationState.account?.id, AccountRelationshipHandler.shared.isBoostedStatusesMuted(accountId: accountId, status: item, modelContext: modelContext) {
continue
}
inPlaceStatuses.append(StatusModel(status: item))
}
// Get only visible statuses.
let visibleStatuses = HomeTimelineService.shared.getVisibleStatuses(accountId: accountId,
statuses: statuses,
hideStatusesWithoutAlt: self.applicationState.hideStatusesWithoutAlt,
modelContext: modelContext)
if self.listType == .home {
// Remeber first status returned by API in user context (when it's newer then remembered).
try AccountDataHandler.shared.update(lastSeenStatusId: nil, lastLoadedStatusId: statuses.first?.id, accountId: accountId, modelContext: modelContext)
// Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)
}
// Map to view models.
let statusModels = visibleStatuses.map({ StatusModel(status: $0) })
// Prefetch images.
self.prefetch(statusModels: inPlaceStatuses)
self.prefetch(statusModels: statusModels)
// Append to empty list.
self.statusViewModels.append(contentsOf: inPlaceStatuses)
self.statusViewModels.append(contentsOf: statusModels)
}
private func loadMoreStatuses() async throws {
if let lastStatusId = self.lastStatusId {
let previousStatuses = try await self.loadFromApi(maxId: lastStatusId)
if let lastStatusId = self.lastStatusId, let accountId = self.applicationState.account?.id {
let statuses = try await self.loadFromApi(maxId: lastStatusId)
if previousStatuses.isEmpty {
if statuses.isEmpty {
self.allItemsLoaded = true
return
}
// Now we have new last status.
if let lastStatusId = previousStatuses.last?.id {
if let lastStatusId = statuses.last?.id {
self.lastStatusId = lastStatusId
}
// Get only statuses with images.
var inPlaceStatuses: [StatusModel] = []
for item in previousStatuses.getStatusesWithImagesOnly() {
// We have to hide statuses without ALT text.
if self.shouldHideStatusWithoutAlt(status: item) {
continue
}
// Get only visible statuses.
let visibleStatuses = HomeTimelineService.shared.getVisibleStatuses(accountId: accountId,
statuses: statuses,
hideStatusesWithoutAlt: self.applicationState.hideStatusesWithoutAlt,
modelContext: modelContext)
// We have to skip statuses that are boosted from muted accounts.
if let accountId = self.applicationState.account?.id, AccountRelationshipHandler.shared.isBoostedStatusesMuted(accountId: accountId, status: item, modelContext: modelContext) {
continue
}
inPlaceStatuses.append(StatusModel(status: item))
if self.listType == .home {
// Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)
}
// Map to view models.
let statusModels = visibleStatuses.map({ StatusModel(status: $0) })
// Prefetch images.
self.prefetch(statusModels: inPlaceStatuses)
self.prefetch(statusModels: statusModels)
// Append statuses to existing array of statuses (at the end).
self.statusViewModels.append(contentsOf: inPlaceStatuses)
self.statusViewModels.append(contentsOf: statusModels)
}
}
private func loadTopStatuses() async throws {
private func refreshStatuses() async throws {
guard let accountId = self.applicationState.account?.id else {
return
}
let statuses = try await self.loadFromApi()
if statuses.isEmpty {
@ -256,28 +263,29 @@ struct StatusesView: View {
// Remember last status id returned by API.
self.lastStatusId = statuses.last?.id
// Get only statuses with images.
var inPlaceStatuses: [StatusModel] = []
for item in statuses.getStatusesWithImagesOnly() {
// We have to hide statuses without ALT text.
if self.shouldHideStatusWithoutAlt(status: item) {
continue
}
// We have to skip statuses that are boosted from muted accounts.
if let accountId = self.applicationState.account?.id, AccountRelationshipHandler.shared.isBoostedStatusesMuted(accountId: accountId, status: item, modelContext: modelContext) {
continue
}
inPlaceStatuses.append(StatusModel(status: item))
// Get only visible statuses.
let visibleStatuses = HomeTimelineService.shared.getVisibleStatuses(accountId: accountId,
statuses: statuses,
hideStatusesWithoutAlt: self.applicationState.hideStatusesWithoutAlt,
modelContext: modelContext)
if self.listType == .home {
// Remeber first status returned by API in user context (when it's newer then remembered).
try AccountDataHandler.shared.update(lastSeenStatusId: self.statusViewModels.first?.id, lastLoadedStatusId: statuses.first?.id, accountId: accountId, modelContext: modelContext)
// Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)
}
// Map to view models.
let statusModels = visibleStatuses.map({ StatusModel(status: $0) })
// Prefetch images.
self.prefetch(statusModels: inPlaceStatuses)
self.prefetch(statusModels: statusModels)
// Replace old collection with new one.
self.waterfallId = String.randomString(length: 8)
self.statusViewModels = inPlaceStatuses
self.statusViewModels = statusModels
}
private func loadFromApi(maxId: String? = nil, sinceId: String? = nil, minId: String? = nil) async throws -> [Status] {

View File

@ -97,7 +97,7 @@ struct UserProfileStatusesView: View {
do {
try await self.loadMoreStatuses()
} catch {
ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadStatuses", showToastr: true)
ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadStatuses", showToastr: !Task.isCancelled)
}
}
Spacer()

View File

@ -1,96 +0,0 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import SwiftUI
import ServicesKit
import WidgetsKit
struct ImageRow: View {
private let status: StatusData
private let attachmentsData: [AttachmentData]
private let firstAttachment: AttachmentData?
@State private var imageHeight: Double
@State private var imageWidth: Double
@State private var selected: String
init(statusData: StatusData) {
self.status = statusData
self.attachmentsData = statusData.attachments()
self.firstAttachment = self.attachmentsData.first
self.selected = String.empty()
// Calculate size of frame (first from cache, then from real image, then from metadata).
if let firstAttachment, let size = ImageSizeService.shared.get(for: firstAttachment.url) {
let calculatedSize = ImageSizeService.shared.calculate(width: size.width, height: size.height, andContainerWidth: UIScreen.main.bounds.size.width)
self.imageWidth = calculatedSize.width
self.imageHeight = calculatedSize.height
} else if let firstAttachment, firstAttachment.metaImageWidth > 0 && firstAttachment.metaImageHeight > 0 {
ImageSizeService.shared.save(for: firstAttachment.url,
width: firstAttachment.metaImageWidth,
height: firstAttachment.metaImageHeight)
let size = ImageSizeService.shared.calculate(for: firstAttachment.url, andContainerWidth: UIScreen.main.bounds.size.width)
self.imageWidth = size.width
self.imageHeight = size.height
} else {
self.imageHeight = UIScreen.main.bounds.width
self.imageWidth = UIScreen.main.bounds.width
}
}
var body: some View {
if self.attachmentsData.count == 1, let firstAttachment = self.firstAttachment {
ImageRowItem(status: self.status, attachmentData: firstAttachment) { (imageWidth, imageHeight) in
// When we download image and calculate real size we have to change view size.
if imageWidth != self.imageWidth || imageHeight != self.imageHeight {
withAnimation(.linear(duration: 0.4)) {
self.imageWidth = imageWidth
self.imageHeight = imageHeight
}
}
}
.frame(width: self.imageWidth, height: self.imageHeight)
} else {
TabView(selection: $selected) {
ForEach(self.attachmentsData, id: \.id) { attachmentData in
ImageRowItem(status: self.status, attachmentData: attachmentData) { (imageWidth, imageHeight) in
// When we download image and calculate real size we have to change view size (only when image is now visible).
if attachmentData.id == self.selected {
if imageWidth != self.imageWidth || imageHeight != self.imageHeight {
withAnimation(.linear(duration: 0.4)) {
self.imageWidth = imageWidth
self.imageHeight = imageHeight
}
}
}
}
.tag(attachmentData.id)
}
}
.onFirstAppear {
self.selected = self.attachmentsData.first?.id ?? String.empty()
}
.onChange(of: selected) { oldAttachmentId, newAttachmentId in
if let attachment = attachmentsData.first(where: { item in item.id == newAttachmentId }) {
let size = ImageSizeService.shared.calculate(width: Double(attachment.metaImageWidth),
height: Double(attachment.metaImageHeight),
andContainerWidth: UIScreen.main.bounds.size.width)
if size.width != self.imageWidth || size.height != self.imageHeight {
withAnimation(.linear(duration: 0.4)) {
self.imageWidth = size.width
self.imageHeight = size.height
}
}
}
}
.frame(width: self.imageWidth, height: self.imageHeight)
.tabViewStyle(.page(indexDisplayMode: .never))
.overlay(CustomPageTabViewStyleView(pages: self.attachmentsData, currentId: $selected))
}
}
}

View File

@ -1,282 +0,0 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import SwiftUI
import Nuke
import ClientKit
import ServicesKit
import EnvironmentKit
import WidgetsKit
@MainActor
struct ImageRowItem: View {
@Environment(ApplicationState.self) var applicationState
@Environment(Client.self) var client
@Environment(RouterPath.self) var routerPath
@Environment(\.modelContext) private var modelContext
private let status: StatusData
private let attachmentData: AttachmentData
private let imageFromCache: Bool
@State private var uiImage: UIImage?
@State private var showThumbImage = false
@State private var cancelled = false
@State private var error: Error?
@State private var opacity = 1.0
@State private var isFavourited = false
private let onImageDownloaded: (Double, Double) -> Void
init(status: StatusData, attachmentData: AttachmentData, onImageDownloaded: @escaping (_: Double, _: Double) -> Void) {
self.status = status
self.attachmentData = attachmentData
self.onImageDownloaded = onImageDownloaded
// When we are deleting status, for some reason that view is updating during the deleting process,
// unfortunatelly the entity state is faulty and we cannot do any operations on that entity.
if status.isFaulty() || attachmentData.isFaulty() {
self.uiImage = nil
self.imageFromCache = false
return
}
if let imageData = attachmentData.data {
self.uiImage = UIImage(data: imageData)
self.imageFromCache = true
} else {
self.imageFromCache = ImagePipeline.shared.cache.containsCachedImage(for: ImageRequest(url: attachmentData.url))
}
}
var body: some View {
if let uiImage {
if self.status.sensitive && !self.applicationState.showSensitive {
ZStack {
ContentWarning(spoilerText: self.status.spoilerText) {
self.imageContainerView(uiImage: uiImage)
.imageContextMenu(statusData: self.status, attachmentData: self.attachmentData, uiImage: uiImage)
} blurred: {
ZStack {
BlurredImage(blurhash: attachmentData.blurhash)
ImageAvatar(displayName: self.status.accountDisplayName,
avatarUrl: self.status.accountAvatar,
rebloggedAccountDisplayName: self.status.rebloggedAccountDisplayName,
rebloggedAccountAvatar: self.status.rebloggedAccountAvatar) { isAuthor in
if isAuthor {
self.routerPath.navigate(to: .userProfile(accountId: self.status.accountId,
accountDisplayName: self.status.accountDisplayName,
accountUserName: self.status.accountUsername))
} else {
if let rebloggedAccountId = self.status.rebloggedAccountId,
let rebloggedAccountUsername = self.status.rebloggedAccountUsername {
self.routerPath.navigate(to: .userProfile(accountId: rebloggedAccountId,
accountDisplayName: self.status.rebloggedAccountDisplayName,
accountUserName: rebloggedAccountUsername))
}
}
}
}
.onTapGesture {
self.navigateToStatus()
}
}
}
.opacity(self.opacity)
.onAppear {
if self.imageFromCache == false {
self.opacity = 0.0
withAnimation {
self.opacity = 1.0
}
}
}
} else {
self.imageContainerView(uiImage: uiImage)
.imageContextMenu(statusData: self.status, attachmentData: self.attachmentData, uiImage: uiImage)
.opacity(self.opacity)
.onAppear {
if self.imageFromCache == false {
self.opacity = 0.0
withAnimation {
self.opacity = 1.0
}
}
}
}
} else {
if cancelled {
BlurredImage(blurhash: attachmentData.blurhash)
.task {
if !status.isFaulty() && !attachmentData.isFaulty() {
if let imageData = await self.downloadImage(attachmentData: attachmentData),
let downloadedImage = UIImage(data: imageData) {
self.setVariables(imageData: imageData, downloadedImage: downloadedImage)
}
}
}
} else if let error {
ZStack {
BlurredImage(blurhash: attachmentData.blurhash)
ErrorView(error: error) {
if !status.isFaulty() && !attachmentData.isFaulty() {
if let imageData = await self.downloadImage(attachmentData: attachmentData),
let downloadedImage = UIImage(data: imageData) {
self.setVariables(imageData: imageData, downloadedImage: downloadedImage)
}
}
}
.padding()
}
} else {
BlurredImage(blurhash: attachmentData.blurhash)
.onTapGesture {
self.navigateToStatus()
}
.task {
if !status.isFaulty() && !attachmentData.isFaulty() {
if let imageData = await self.downloadImage(attachmentData: attachmentData),
let downloadedImage = UIImage(data: imageData) {
self.setVariables(imageData: imageData, downloadedImage: downloadedImage)
}
}
}
}
}
}
@ViewBuilder
private func imageContainerView(uiImage: UIImage) -> some View {
ZStack {
self.imageView(uiImage: uiImage)
ImageAvatar(displayName: self.status.accountDisplayName,
avatarUrl: self.status.accountAvatar,
rebloggedAccountDisplayName: self.status.rebloggedAccountDisplayName,
rebloggedAccountAvatar: self.status.rebloggedAccountAvatar) { isAuthor in
if isAuthor {
self.routerPath.navigate(to: .userProfile(accountId: self.status.accountId,
accountDisplayName: self.status.accountDisplayName,
accountUserName: self.status.accountUsername))
} else {
if let rebloggedAccountId = self.status.rebloggedAccountId,
let rebloggedAccountUsername = self.status.rebloggedAccountUsername {
self.routerPath.navigate(to: .userProfile(accountId: rebloggedAccountId,
accountDisplayName: self.status.rebloggedAccountDisplayName,
accountUserName: rebloggedAccountUsername))
}
}
}
ImageFavourite(isFavourited: $isFavourited)
ImageAlternativeText(text: self.attachmentData.text) { text in
self.routerPath.presentedAlert = .alternativeText(text: text)
}
FavouriteTouch(showFavouriteAnimation: $showThumbImage)
}
}
@ViewBuilder
func reblogInformation() -> some View {
if let rebloggedAccountAvatar = self.status.rebloggedAccountAvatar,
let rebloggedAccountDisplayName = self.status.rebloggedAccountDisplayName {
HStack(alignment: .center, spacing: 4) {
UserAvatar(accountAvatar: rebloggedAccountAvatar, size: .mini)
Text(rebloggedAccountDisplayName)
Image("custom.rocket")
.padding(.trailing, 8)
}
.font(.footnote)
.foregroundColor(Color.mainTextColor.opacity(0.4))
.background(Color.mainTextColor.opacity(0.1))
.clipShape(Capsule())
}
}
@ViewBuilder
private func imageView(uiImage: UIImage) -> some View {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.onTapGesture(count: 2) {
Task {
// Update favourite in Pixelfed server.
_ = try? await self.client.statuses?.favourite(statusId: self.status.id)
// Update favourite in local cache (core data).
if let accountId = self.applicationState.account?.id {
StatusDataHandler.shared.setFavourited(accountId: accountId, statusId: self.status.id, modelContext: modelContext)
}
}
// Run adnimation and haptic feedback.
self.showThumbImage = true
HapticService.shared.fireHaptic(of: .buttonPress)
// Mark favourite booleans used to show star in the timeline view.
withAnimation(.default.delay(2.0)) {
self.isFavourited = true
}
}
.onTapGesture {
self.navigateToStatus()
}
.onAppear {
self.isFavourited = self.status.favourited
}
}
private func downloadImage(attachmentData: AttachmentData) async -> Data? {
do {
if let imageData = try await RemoteFileService.shared.fetchData(url: attachmentData.url) {
return imageData
}
return nil
} catch {
if !Task.isCancelled {
ErrorService.shared.handle(error, message: "global.error.errorDuringImageDownload")
self.error = error
} else {
ErrorService.shared.handle(error, message: "global.error.canceledImageDownload")
self.cancelled = true
}
return nil
}
}
private func setVariables(imageData: Data, downloadedImage: UIImage) {
ImageSizeService.shared.save(for: attachmentData.url,
width: downloadedImage.size.width,
height: downloadedImage.size.height)
let size = ImageSizeService.shared.calculate(for: attachmentData.url, andContainerWidth: UIScreen.main.bounds.size.width)
self.onImageDownloaded(size.width, size.height)
self.uiImage = downloadedImage
HomeTimelineService.shared.update(attachment: attachmentData,
withData: imageData,
imageWidth: downloadedImage.size.width,
imageHeight: downloadedImage.size.height,
modelContext: modelContext)
self.error = nil
self.cancelled = false
}
private func navigateToStatus() {
self.routerPath.navigate(to: .status(
id: status.id,
blurhash: status.attachments().first?.blurhash,
highestImageUrl: status.attachments().getHighestImage()?.url,
metaImageWidth: status.attachments().first?.metaImageWidth,
metaImageHeight: status.attachments().first?.metaImageHeight
))
}
}

View File

@ -125,17 +125,18 @@ struct ImageRowItemAsync: View {
}
} else if state.error != nil {
ZStack {
Rectangle()
.fill(Color.placeholderText)
.scaledToFill()
BlurredImage(blurhash: attachment.blurhash)
VStack(alignment: .center) {
Spacer()
Text("global.error.errorDuringImageDownload", comment: "Cannot download image")
.foregroundColor(.systemBackground)
.foregroundColor(.white)
Spacer()
}
}
.onTapGesture {
self.navigateToStatus()
}
} else {
VStack(alignment: .center) {
BlurredImage(blurhash: attachment.blurhash)

View File

@ -201,9 +201,6 @@ struct InteractionRow: View {
// Remove from server.
try await self.client.statuses?.delete(statusId: self.statusModel.id)
// Remove from database.
StatusDataHandler.shared.remove(accountId: self.statusModel.account.id, statusId: self.statusModel.id, modelContext: modelContext)
ToastrService.shared.showSuccess("status.title.statusDeleted", imageSystemName: "checkmark.circle.fill")
self.delete?()
} catch {

View File

@ -34,7 +34,7 @@ struct PhotoProvider: TimelineProvider {
}
func getWidgetEntriesForSnapshot() async -> PhotoWidgetEntry {
let entriesFromDatabase = await self.getWidgetEntriesFromDatabase(length: 1)
let entriesFromDatabase = await self.getWidgetEntriesFromServer(length: 1)
if let firstEntry = entriesFromDatabase.first {
return firstEntry
}
@ -48,11 +48,6 @@ struct PhotoProvider: TimelineProvider {
return entriesFromServer
}
let entriesFromDatabase = await self.getWidgetEntriesFromDatabase(length: 3)
if entriesFromDatabase.isEmpty == false {
return entriesFromDatabase
}
return [StatusFetcher.shared.placeholder()]
}
@ -63,8 +58,4 @@ struct PhotoProvider: TimelineProvider {
return []
}
}
func getWidgetEntriesFromDatabase(length: Int) async -> [PhotoWidgetEntry] {
return await StatusFetcher.shared.fetchWidgetEntriesFromDatabase(length: length)
}
}

View File

@ -72,42 +72,6 @@ public class StatusFetcher {
return widgetEntries.shuffled()
}
@MainActor
func fetchWidgetEntriesFromDatabase(length: Int) async -> [PhotoWidgetEntry] {
let modelContext = SwiftDataHandler.shared.sharedModelContainer.mainContext
let defaultSettings = ApplicationSettingsHandler.shared.get(modelContext: modelContext)
guard let accountId = defaultSettings.currentAccount else {
return [self.placeholder()]
}
let attachmentDatas = AttachmentDataHandler.shared.getDownloadedAttachmentData(accountId: accountId,
length: length,
modelContext: modelContext)
var widgetEntries: [PhotoWidgetEntry] = []
for attachmentData in attachmentDatas {
guard let imageData = attachmentData.data, let uiImage = UIImage(data: imageData) else {
continue
}
let uiAvatar = await FileFetcher.shared.getImage(url: attachmentData.statusRelation?.accountAvatar)
let displayDate = Calendar.current.date(byAdding: .minute, value: widgetEntries.count * 20, to: Date())
widgetEntries.append(PhotoWidgetEntry(date: displayDate ?? Date(),
image: uiImage,
avatar: uiAvatar,
displayName: attachmentData.statusRelation?.accountDisplayName,
statusId: attachmentData.statusId))
}
if widgetEntries.isEmpty {
widgetEntries.append(self.placeholder())
}
return widgetEntries.shuffled()
}
func placeholder() -> PhotoWidgetEntry {
PhotoWidgetEntry(date: Date(), image: nil, avatar: nil, displayName: "Caroline Rick", statusId: "")
}