Change home timeline
This commit is contained in:
parent
bb15356e88
commit
f063338ce4
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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).")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,8 +13,6 @@ public class SwiftDataHandler {
|
|||
|
||||
lazy var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
AttachmentData.self,
|
||||
StatusData.self,
|
||||
ApplicationSettings.self,
|
||||
AccountData.self,
|
||||
ViewedStatus.self,
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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" : {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
},
|
||||
"global.error.downloadingImageFailed" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
@ -24,6 +27,7 @@
|
|||
}
|
||||
},
|
||||
"global.error.unexpected" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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] {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: "")
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue