diff --git a/MastodonKit/Sources/MastodonKit/Entities/Application.swift b/MastodonKit/Sources/MastodonKit/Entities/Application.swift index d48ea26..b8cfb3a 100644 --- a/MastodonKit/Sources/MastodonKit/Entities/Application.swift +++ b/MastodonKit/Sources/MastodonKit/Entities/Application.swift @@ -44,20 +44,19 @@ public class Application: BaseApplication { public required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(String.self, forKey: .id) self.redirectUri = try container.decode(String.self, forKey: .redirectUri) self.clientId = try container.decode(String.self, forKey: .clientId) self.clientSecret = try container.decode(String.self, forKey: .clientSecret) - self.vapidKey = try? container.decode(String.self, forKey: .vapidKey) + self.vapidKey = try? container.decodeIfPresent(String.self, forKey: .vapidKey) - let superDecoder = try container.superDecoder() - try super.init(from: superDecoder) + try super.init(from: decoder) } public override func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) + try super.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(redirectUri, forKey: .redirectUri) try container.encode(clientId, forKey: .clientId) @@ -66,8 +65,5 @@ public class Application: BaseApplication { if let vapidKey { try container.encode(vapidKey, forKey: .vapidKey) } - - let superEncoder = container.superEncoder() - try super.encode(to: superEncoder) } } diff --git a/MastodonKit/Sources/MastodonKit/Entities/BaseApplication.swift b/MastodonKit/Sources/MastodonKit/Entities/BaseApplication.swift index 7faa911..002b9f1 100644 --- a/MastodonKit/Sources/MastodonKit/Entities/BaseApplication.swift +++ b/MastodonKit/Sources/MastodonKit/Entities/BaseApplication.swift @@ -30,7 +30,7 @@ public class BaseApplication: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) self.name = try container.decode(String.self, forKey: .name) - self.website = try? container.decode(URL.self, forKey: .website) + self.website = try? container.decodeIfPresent(URL.self, forKey: .website) } public func encode(to encoder: Encoder) throws { diff --git a/MastodonKit/Sources/MastodonKit/Entities/Instance.swift b/MastodonKit/Sources/MastodonKit/Entities/Instance.swift index 298a696..6742358 100644 --- a/MastodonKit/Sources/MastodonKit/Entities/Instance.swift +++ b/MastodonKit/Sources/MastodonKit/Entities/Instance.swift @@ -9,69 +9,75 @@ import Foundation /// Represents the software instance of Mastodon running on this domain. public struct Instance: Codable { /// The domain name of the instance. - public let domain: String + public let uri: String - /// The title of the website. - public let title: String? - /// The version of Mastodon installed on the instance. public let version: String + /// The title of the website. + public let title: String? + /// The URL for the source code of the software running on this instance, in keeping with AGPL license requirements. public let sourceUrl: URL? /// A short, plain-text description defined by the admin. + public let shortDescription: String? + + /// A plain-text description defined by the admin. public let description: String? - /// Usage data for this instance. - public let usage: Usage? - /// The URL for the thumbnail image. - public let thumbnail: Thumbnail? + public let thumbnail: URL? /// Primary languages of the website and its staff. Array of String (ISO 639-1 two-letter code). public let languages: [String]? /// Configured values and limits for this website. public let configuration: Configuration? + + /// If registration new accounts on server is enabled + public let registrations: Bool - /// Information about registering for this website. - public let registrations: Registration? - - /// Hints related to contacting a representative of the website. - public let contact: Contact? + /// If approval for registration account is mandatory. + public let approvalRequired: Bool /// An itemized list of rules for this website. public let rules: [Rule]? + /// Main contact email. + public let email: String? + enum CodingKeys: String, CodingKey { - case domain + case uri case title case version case sourceUrl = "source_url" + case shortDescription = "short_description" case description - case usage case thumbnail case languages case configuration - case registrations - case contact case rules + case email + case registrations + case approvalRequired = "approval_required" } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.domain = try container.decode(String.self, forKey: .domain) - self.title = try? container.decodeIfPresent(String.self, forKey: .title) + self.uri = try container.decode(String.self, forKey: .uri) self.version = try container.decode(String.self, forKey: .version) + + self.title = try? container.decodeIfPresent(String.self, forKey: .title) self.sourceUrl = try? container.decodeIfPresent(URL.self, forKey: .sourceUrl) + self.shortDescription = try? container.decodeIfPresent(String.self, forKey: .shortDescription) self.description = try? container.decodeIfPresent(String.self, forKey: .description) - self.usage = try? container.decodeIfPresent(Usage.self, forKey: .usage) - self.thumbnail = try? container.decodeIfPresent(Thumbnail.self, forKey: .thumbnail) + self.thumbnail = try? container.decodeIfPresent(URL.self, forKey: .thumbnail) self.languages = try? container.decodeIfPresent([String].self, forKey: .languages) self.configuration = try? container.decodeIfPresent(Configuration.self, forKey: .configuration) - self.registrations = try? container.decodeIfPresent(Registration.self, forKey: .registrations) - self.contact = try? container.decodeIfPresent(Contact.self, forKey: .contact) self.rules = try? container.decodeIfPresent([Rule].self, forKey: .rules) + self.email = try? container.decodeIfPresent(String.self, forKey: .email) + self.registrations = (try? container.decodeIfPresent(Bool.self, forKey: .registrations)) ?? false + self.approvalRequired = (try? container.decodeIfPresent(Bool.self, forKey: .approvalRequired)) ?? false } } diff --git a/MastodonKit/Sources/MastodonKit/MastodonClient.swift b/MastodonKit/Sources/MastodonKit/MastodonClient.swift index 3028ea6..56431e4 100644 --- a/MastodonKit/Sources/MastodonKit/MastodonClient.swift +++ b/MastodonKit/Sources/MastodonKit/MastodonClient.swift @@ -70,7 +70,18 @@ public class MastodonClient: MastodonClientProtocol { throw NetworkError.notSuccessResponse(response) } - return try JSONDecoder().decode(type, from: data) + #if DEBUG + do { + return try JSONDecoder().decode(type, from: data) + } catch { + let json = String(data: data, encoding: .utf8)! + print(json) + + throw error + } + #else + return try JSONDecoder().decode(type, from: data) + #endif } } diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index c5c0003..e5ecbfb 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DE92966E4F9001D9973 /* AnimatePlaceholderModifier.swift */; }; F8341F90295C636C009C8EE6 /* Data+Exif.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8341F8F295C636C009C8EE6 /* Data+Exif.swift */; }; F83901A6295D8EC000456AE2 /* LabelIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83901A5295D8EC000456AE2 /* LabelIcon.swift */; }; + F83CBEFB298298A1002972C8 /* ImageCarouselPicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83CBEFA298298A1002972C8 /* ImageCarouselPicture.swift */; }; F857F9FD297D8ED3002C109C /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F857F9FC297D8ED3002C109C /* ActionMenu.swift */; }; F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D4970296402DC00751DF7 /* AuthorizationService.swift */; }; F85D4973296406E700751DF7 /* BottomRight.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D4972296406E700751DF7 /* BottomRight.swift */; }; @@ -135,6 +136,8 @@ F8210DE92966E4F9001D9973 /* AnimatePlaceholderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatePlaceholderModifier.swift; sourceTree = ""; }; F8341F8F295C636C009C8EE6 /* Data+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Exif.swift"; sourceTree = ""; }; F83901A5295D8EC000456AE2 /* LabelIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelIcon.swift; sourceTree = ""; }; + F83CBEFA298298A1002972C8 /* ImageCarouselPicture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCarouselPicture.swift; sourceTree = ""; }; + F85183232981C2CE001BB950 /* Vernissage20230125-001.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Vernissage20230125-001.xcdatamodel"; sourceTree = ""; }; F857F9FC297D8ED3002C109C /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = ""; }; F85D4970296402DC00751DF7 /* AuthorizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationService.swift; sourceTree = ""; }; F85D4972296406E700751DF7 /* BottomRight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomRight.swift; sourceTree = ""; }; @@ -201,6 +204,7 @@ F8984E4C296B648000A2610F /* UIImage+Blurhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Blurhash.swift"; sourceTree = ""; }; F898DE6F2972868A004B4A6A /* String+Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Empty.swift"; sourceTree = ""; }; F898DE7129728CB2004B4A6A /* CommentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentViewModel.swift; sourceTree = ""; }; + F8995E432982865C004C191C /* Vernissage20230126-001.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Vernissage20230126-001.xcdatamodel"; sourceTree = ""; }; F8996DEA2971D29D0043EEC6 /* View+Transition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Transition.swift"; sourceTree = ""; }; F89992C8296D6DC7005994BF /* CommentBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentBody.swift; sourceTree = ""; }; F89992CB296D9231005994BF /* StatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusViewModel.swift; sourceTree = ""; }; @@ -351,6 +355,7 @@ F85D497629640A5200751DF7 /* ImageRow.swift */, F8210DCE2966B600001D9973 /* ImageRowAsync.swift */, F85D497829640B9D00751DF7 /* ImagesCarousel.swift */, + F83CBEFA298298A1002972C8 /* ImageCarouselPicture.swift */, F85D497A29640C8200751DF7 /* UsernameRow.swift */, F85D497C29640D5900751DF7 /* InteractionRow.swift */, F897978729681B9C00B22335 /* UserAvatar.swift */, @@ -685,6 +690,7 @@ F88E4D54297EA7EE0057491A /* MarkdownFormattedText.swift in Sources */, F866F6A1296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift in Sources */, F8C14394296AF21B001FE31D /* Double+Round.swift in Sources */, + F83CBEFB298298A1002972C8 /* ImageCarouselPicture.swift in Sources */, F89A46DC296EAACE0062125F /* SettingsView.swift in Sources */, F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */, F8A93D802965FED4001D8331 /* AccountService.swift in Sources */, @@ -993,12 +999,14 @@ F88C2476295C37BB0006098B /* Vernissage.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + F8995E432982865C004C191C /* Vernissage20230126-001.xcdatamodel */, + F85183232981C2CE001BB950 /* Vernissage20230125-001.xcdatamodel */, F86167C9297FF423004D1F67 /* Vernissage20230121-001.xcdatamodel */, F89D6C3B29716DBC001DA3D4 /* Vernissage20230113-001.xcdatamodel */, F8AF2A61297073FE00D2DA3F /* Vernissage20230112-001.xcdatamodel */, F88C2477295C37BB0006098B /* Vernissage.xcdatamodel */, ); - currentVersion = F86167C9297FF423004D1F67 /* Vernissage20230121-001.xcdatamodel */; + currentVersion = F8995E432982865C004C191C /* Vernissage20230126-001.xcdatamodel */; path = Vernissage.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Vernissage/CoreData/ApplicationSettingsHandler.swift b/Vernissage/CoreData/ApplicationSettingsHandler.swift index cc6f310..e94a4d9 100644 --- a/Vernissage/CoreData/ApplicationSettingsHandler.swift +++ b/Vernissage/CoreData/ApplicationSettingsHandler.swift @@ -25,6 +25,9 @@ class ApplicationSettingsHandler { return settings } else { let settings = self.createApplicationSettingsEntity() + settings.avatarShape = Int32(AvatarShape.circle.rawValue) + settings.theme = Int32(Theme.system.rawValue) + settings.tintColor = Int32(TintColor.accentColor2.rawValue) CoreDataHandler.shared.save() return settings diff --git a/Vernissage/CoreData/StatusData+CoreDataProperties.swift b/Vernissage/CoreData/StatusData+CoreDataProperties.swift index 8af6863..9cf2b70 100644 --- a/Vernissage/CoreData/StatusData+CoreDataProperties.swift +++ b/Vernissage/CoreData/StatusData+CoreDataProperties.swift @@ -39,7 +39,12 @@ extension StatusData { @NSManaged public var visibility: String @NSManaged public var attachmentRelation: Set? @NSManaged public var pixelfedAccount: AccountData - + + @NSManaged public var rebloggedStatusId: String? + @NSManaged public var rebloggedAccountAvatar: URL? + @NSManaged public var rebloggedAccountDisplayName: String? + @NSManaged public var rebloggedAccountId: String? + @NSManaged public var rebloggedAccountUsername: String? } // MARK: Generated accessors for attachmentRelation diff --git a/Vernissage/CoreData/StatusData+Status.swift b/Vernissage/CoreData/StatusData+Status.swift index 6a6df50..9fa94b0 100644 --- a/Vernissage/CoreData/StatusData+Status.swift +++ b/Vernissage/CoreData/StatusData+Status.swift @@ -9,29 +9,39 @@ import MastodonKit extension StatusData { func copyFrom(_ status: Status) { - 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 - 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 + if let reblog = status.reblog { + self.copyFrom(reblog) + + self.rebloggedStatusId = status.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 + 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 + } } } diff --git a/Vernissage/Extensions/Status+MediaAttachmentType.swift b/Vernissage/Extensions/Status+MediaAttachmentType.swift index 92c3568..fad0a30 100644 --- a/Vernissage/Extensions/Status+MediaAttachmentType.swift +++ b/Vernissage/Extensions/Status+MediaAttachmentType.swift @@ -10,9 +10,22 @@ import MastodonKit extension [Status] { func getStatusesWithImagesOnly() -> [Status] { return self.filter { status in - status.mediaAttachments.contains { mediaAttachment in - mediaAttachment.type == .image - } + status.statusContainsImage() } } } + +extension Status { + func statusContainsImage() -> Bool { + return getAllImageMediaAttachments().isEmpty == false + } + + func getAllImageMediaAttachments() -> [MediaAttachment] { + if let reblog = self.reblog { + // If status is rebloged the we have to check if orginal status contains image. + return reblog.mediaAttachments.filter { mediaAttachment in mediaAttachment.type == .image } + } + + return self.mediaAttachments.filter { mediaAttachment in mediaAttachment.type == .image } + } +} diff --git a/Vernissage/Services/AuthorizationService.swift b/Vernissage/Services/AuthorizationService.swift index 9854607..9289704 100644 --- a/Vernissage/Services/AuthorizationService.swift +++ b/Vernissage/Services/AuthorizationService.swift @@ -43,7 +43,6 @@ public class AuthorizationService { // Verify address. let instanceInformation = try await client.readInstanceInformation() - print(instanceInformation) // Create application (we will get clientId amd clientSecret). let oAuthApp = try await client.createApp( diff --git a/Vernissage/Services/HomeTimelineService.swift b/Vernissage/Services/HomeTimelineService.swift index dd45e71..2cd09be 100644 --- a/Vernissage/Services/HomeTimelineService.swift +++ b/Vernissage/Services/HomeTimelineService.swift @@ -23,10 +23,10 @@ public class HomeTimelineService { return 0 } - let statuses = try await self.loadData(for: accountData, on: backgroundContext, maxId: oldestStatus.id) + let newStatuses = try await self.loadData(for: accountData, on: backgroundContext, maxId: oldestStatus.id) try backgroundContext.save() - return statuses.count + return newStatuses.count } public func onTopOfList(for accountData: AccountData) async throws -> Int { @@ -77,7 +77,7 @@ public class HomeTimelineService { // Save statuses in database (and download images). if !statusesToAdd.isEmpty { - try await self.save(statuses: statusesToAdd, accountData: accountData, on: backgroundContext) + _ = try await self.save(statuses: statusesToAdd, accountData: accountData, on: backgroundContext) } } @@ -91,42 +91,33 @@ public class HomeTimelineService { let statuses = try await client.getHomeTimeline(maxId: maxId, minId: minId, limit: 20) // Save statuses in database (and download images). - try await self.save(statuses: statuses, accountData: accountData, on: backgroundContext) - - return statuses + return try await self.save(statuses: statuses, accountData: accountData, on: backgroundContext) } public func updateStatus(_ statusData: StatusData, accountData: AccountData, basedOn status: Status) async throws -> StatusData? { // Load data from API and operate on CoreData on background context. let backgroundContext = CoreDataHandler.shared.newBackgroundContext() - - // Download all images from server. - let attachmentsData = await self.fetchAllImages(statuses: [status]) - + // Update status data in database. - try await self.copy(from: status, to: statusData, attachmentsData: attachmentsData, on: backgroundContext) + self.copy(from: status, to: statusData, on: backgroundContext) try backgroundContext.save() return statusData } - private func save(statuses: [Status], accountData: AccountData, on backgroundContext: NSManagedObjectContext) async throws { - // Download all images from server. - let attachmentsData = await self.fetchAllImages(statuses: statuses) + public func updateAttachmentDataImage(attachmentData: AttachmentData, imageData: Data) { + attachmentData.data = imageData + self.setExifProperties(in: attachmentData, from: imageData) + CoreDataHandler.shared.save() + } + + private func save(statuses: [Status], accountData: AccountData, on backgroundContext: NSManagedObjectContext) async throws -> [Status] { + // Proceed statuses with images only. + let statusesWithImages = statuses.getStatusesWithImagesOnly() + // Save status data in database. - for status in statuses { - let contains = attachmentsData.contains { (key: String, value: Data) in - status.mediaAttachments.contains { attachment in - attachment.id == key - } - } - - // We are adding status only when we have at least one image for status. - if contains == false { - continue - } - + for status in statusesWithImages { guard let dbAccount = AccountDataHandler.shared.getAccountData(accountId: accountData.id, viewContext: backgroundContext) else { throw DatabaseError.cannotDownloadAccount } @@ -136,17 +127,19 @@ public class HomeTimelineService { statusData.pixelfedAccount = dbAccount dbAccount.addToStatuses(statusData) - try await self.copy(from: status, to: statusData, attachmentsData: attachmentsData, on: backgroundContext) + self.copy(from: status, to: statusData, on: backgroundContext) } + + return statusesWithImages } - private func copy(from status: Status, to statusData: StatusData, attachmentsData: Dictionary, on backgroundContext: NSManagedObjectContext) async throws { + private func copy(from status: Status, + to statusData: StatusData, + on backgroundContext: NSManagedObjectContext + ) { statusData.copyFrom(status) - for attachment in status.mediaAttachments { - guard let imageData = attachmentsData[attachment.id] else { - continue - } + for attachment in status.getAllImageMediaAttachments() { // Save attachment in database. let attachmentData = statusData.attachments().first { item in item.id == attachment.id } @@ -154,31 +147,7 @@ public class HomeTimelineService { attachmentData.copyFrom(attachment) attachmentData.statusId = statusData.id - attachmentData.data = imageData - - // 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)" - } - } - + if attachmentData.isInserted { attachmentData.statusRelation = statusData statusData.addToAttachmentRelation(attachmentData) @@ -189,10 +158,11 @@ public class HomeTimelineService { public func fetchAllImages(statuses: [Status]) async -> Dictionary { var attachmentUrls: Dictionary = [:] - let statusesWithImages = statuses.getStatusesWithImagesOnly() - statusesWithImages.forEach { status in + statuses.forEach { status in status.mediaAttachments.forEach { attachment in - attachmentUrls[attachment.id] = attachment.url + if attachment.type == .image { + attachmentUrls[attachment.id] = attachment.url + } } } @@ -200,13 +170,15 @@ public class HomeTimelineService { for attachmentUrl in attachmentUrls { taskGroup.addTask { do { + print("Fetching image \(attachmentUrl.value)") if let imageData = try await self.fetchImage(attachmentUrl: attachmentUrl.value) { + print("Image fetched \(attachmentUrl.value)") return (attachmentUrl.key, imageData) } return (attachmentUrl.key, nil) } catch { - ErrorService.shared.handle(error, message: "Fatching all images failed.") + ErrorService.shared.handle(error, message: "Fatching image '\(attachmentUrl.value)' failed.") return (attachmentUrl.key, nil) } } @@ -225,6 +197,31 @@ public class HomeTimelineService { } } + 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 fetchImage(attachmentUrl: URL) async throws -> Data? { guard let data = try await RemoteFileService.shared.fetchData(url: attachmentUrl) else { return nil diff --git a/Vernissage/Vernissage.xcdatamodeld/.xccurrentversion b/Vernissage/Vernissage.xcdatamodeld/.xccurrentversion index 1bced89..f9a1266 100644 --- a/Vernissage/Vernissage.xcdatamodeld/.xccurrentversion +++ b/Vernissage/Vernissage.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Vernissage20230121-001.xcdatamodel + Vernissage20230126-001.xcdatamodel diff --git a/Vernissage/Vernissage.xcdatamodeld/Vernissage20230125-001.xcdatamodel/contents b/Vernissage/Vernissage.xcdatamodeld/Vernissage20230125-001.xcdatamodel/contents new file mode 100644 index 0000000..5767896 --- /dev/null +++ b/Vernissage/Vernissage.xcdatamodeld/Vernissage20230125-001.xcdatamodel/contents @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Vernissage/Vernissage.xcdatamodeld/Vernissage20230126-001.xcdatamodel/contents b/Vernissage/Vernissage.xcdatamodeld/Vernissage20230126-001.xcdatamodel/contents new file mode 100644 index 0000000..7ec5c61 --- /dev/null +++ b/Vernissage/Vernissage.xcdatamodeld/Vernissage20230126-001.xcdatamodel/contents @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Vernissage/ViewModels/AttachmentViewModel.swift b/Vernissage/ViewModels/AttachmentViewModel.swift index a52cc0b..0c54951 100644 --- a/Vernissage/ViewModels/AttachmentViewModel.swift +++ b/Vernissage/ViewModels/AttachmentViewModel.swift @@ -7,7 +7,7 @@ import Foundation import MastodonKit -public class AttachmentViewModel { +public class AttachmentViewModel: ObservableObject { public let id: String public let type: MediaAttachment.MediaAttachmentType public let url: URL @@ -21,11 +21,11 @@ public class AttachmentViewModel { public let metaImageWidth: Int32? public let metaImageHeight: Int32? - public var data: Data? - public var exifCamera: String? - public var exifCreatedDate: String? - public var exifExposure: String? - public var exifLens: String? + @Published public var exifCamera: String? + @Published public var exifCreatedDate: String? + @Published public var exifExposure: String? + @Published public var exifLens: String? + @Published public var data: Data? init(id: String, type: MediaAttachment.MediaAttachmentType, diff --git a/Vernissage/ViewModels/StatusViewModel.swift b/Vernissage/ViewModels/StatusViewModel.swift index 99f2772..0ccd99d 100644 --- a/Vernissage/ViewModels/StatusViewModel.swift +++ b/Vernissage/ViewModels/StatusViewModel.swift @@ -7,7 +7,7 @@ import Foundation import MastodonKit -public class StatusViewModel { +public class StatusViewModel: ObservableObject { public let uniqueId: UUID public let id: EntityId @@ -18,7 +18,7 @@ public class StatusViewModel { public let account: Account public let inReplyToId: EntityId? public let inReplyToAccount: EntityId? - public let reblog: Status? + public let createdAt: String public let reblogsCount: Int public let favouritesCount: Int @@ -31,13 +31,16 @@ public class StatusViewModel { public let muted: Bool public let spoilerText: String? public let visibility: Status.Visibility - public let mediaAttachments: [AttachmentViewModel] public let card: PreviewCard? public let mentions: [Mention] public let tags: [Tag] public let application: BaseApplication? public let place: Place? + public let reblogStatus: Status? + + @Published public var mediaAttachments: [AttachmentViewModel] + public init( id: EntityId, content: Html, @@ -75,7 +78,6 @@ public class StatusViewModel { self.application = application self.inReplyToId = inReplyToId self.inReplyToAccount = inReplyToAccount - self.reblog = reblog self.createdAt = createdAt ?? Date().formatted(.iso8601) self.reblogsCount = reblogsCount self.favouritesCount = favouritesCount @@ -93,42 +95,52 @@ public class StatusViewModel { self.mentions = mentions self.tags = tags self.place = place + self.reblogStatus = nil } init(status: Status) { + + // If status has been rebloged we are saving orginal status here. + let orginalStatus = status.reblog ?? status + self.uniqueId = UUID() - self.id = status.id - self.content = status.content - self.uri = status.uri - self.url = status.url - self.account = status.account - self.inReplyToId = status.inReplyToId - self.inReplyToAccount = status.inReplyToAccount - self.reblog = status.reblog - self.createdAt = status.createdAt - self.reblogsCount = status.reblogsCount - self.favouritesCount = status.favouritesCount - self.repliesCount = status.repliesCount - self.reblogged = status.reblogged - self.favourited = status.favourited - self.sensitive = status.sensitive - self.bookmarked = status.bookmarked - self.pinned = status.pinned - self.muted = status.muted - self.spoilerText = status.spoilerText - self.visibility = status.visibility - self.card = status.card - self.mentions = status.mentions - self.tags = status.tags - self.application = status.application - self.place = status.place + self.id = orginalStatus.id + self.content = orginalStatus.content + self.uri = orginalStatus.uri + self.url = orginalStatus.url + self.account = orginalStatus.account + self.inReplyToId = orginalStatus.inReplyToId + self.inReplyToAccount = orginalStatus.inReplyToAccount + self.createdAt = orginalStatus.createdAt + self.reblogsCount = orginalStatus.reblogsCount + self.favouritesCount = orginalStatus.favouritesCount + self.repliesCount = orginalStatus.repliesCount + self.reblogged = orginalStatus.reblogged + self.favourited = orginalStatus.favourited + self.sensitive = orginalStatus.sensitive + self.bookmarked = orginalStatus.bookmarked + self.pinned = orginalStatus.pinned + self.muted = orginalStatus.muted + self.spoilerText = orginalStatus.spoilerText + self.visibility = orginalStatus.visibility + self.card = orginalStatus.card + self.mentions = orginalStatus.mentions + self.tags = orginalStatus.tags + self.application = orginalStatus.application + self.place = orginalStatus.place var mediaAttachments: [AttachmentViewModel] = [] - for item in status.mediaAttachments { + for item in orginalStatus.mediaAttachments { mediaAttachments.append(AttachmentViewModel(attachment: item)) } self.mediaAttachments = mediaAttachments + + if status.reblog != nil { + self.reblogStatus = status + } else { + self.reblogStatus = nil + } } } diff --git a/Vernissage/Views/HomeFeedView.swift b/Vernissage/Views/HomeFeedView.swift index 37e4b52..f36089b 100644 --- a/Vernissage/Views/HomeFeedView.swift +++ b/Vernissage/Views/HomeFeedView.swift @@ -30,7 +30,7 @@ struct HomeFeedView: View { LazyVGrid(columns: gridColumns) { ForEach(dbStatuses, id: \.self) { item in NavigationLink(value: RouteurDestinations.status( - id: item.id, + id: item.rebloggedStatusId ?? item.id, blurhash: item.attachments().first?.blurhash, metaImageWidth: item.attachments().first?.metaImageWidth, metaImageHeight: item.attachments().first?.metaImageHeight) diff --git a/Vernissage/Views/NotificationsView.swift b/Vernissage/Views/NotificationsView.swift index f31a39a..7add78b 100644 --- a/Vernissage/Views/NotificationsView.swift +++ b/Vernissage/Views/NotificationsView.swift @@ -74,8 +74,6 @@ struct NotificationsView: View { maxId: maxId, minId: minId, limit: 5) - - await self.downloadAllImages(notifications: linkable.data) self.minId = linkable.link?.minId self.maxId = linkable.link?.maxId @@ -98,8 +96,6 @@ struct NotificationsView: View { andContext: self.applicationState.accountData, maxId: self.maxId, limit: self.defaultPageSize) - - await self.downloadAllImages(notifications: linkable.data) self.maxId = linkable.link?.maxId self.notifications.append(contentsOf: linkable.data) @@ -125,8 +121,6 @@ struct NotificationsView: View { return } - await self.downloadAllImages(notifications: linkable.data) - self.minId = linkable.link?.minId self.notifications.insert(contentsOf: linkable.data, at: 0) } catch { diff --git a/Vernissage/Views/SignInView.swift b/Vernissage/Views/SignInView.swift index f4034fd..3e869d1 100644 --- a/Vernissage/Views/SignInView.swift +++ b/Vernissage/Views/SignInView.swift @@ -30,13 +30,17 @@ struct SignInView: View { Button("Go") { Task { - try await AuthorizationService.shared.signIn(serverAddress: serverAddress, { accountData in - DispatchQueue.main.async { - self.applicationState.accountData = accountData - onSignInStateChenge?(.mainView) - dismiss() - } - }) + do { + try await AuthorizationService.shared.signIn(serverAddress: serverAddress, { accountData in + DispatchQueue.main.async { + self.applicationState.accountData = accountData + onSignInStateChenge?(.mainView) + dismiss() + } + }) + } catch { + ErrorService.shared.handle(error, message: "Error during communication with server", showToastr: true) + } } } } diff --git a/Vernissage/Views/StatusView.swift b/Vernissage/Views/StatusView.swift index b3f47c8..c78e742 100644 --- a/Vernissage/Views/StatusView.swift +++ b/Vernissage/Views/StatusView.swift @@ -33,7 +33,7 @@ struct StatusView: View { var body: some View { ScrollView { if let statusViewModel = self.statusViewModel { - VStack (alignment: .leading) { + VStack (alignment: .leading) { ImagesCarousel(attachments: statusViewModel.mediaAttachments, selectedAttachmentId: $selectedAttachmentId, exifCamera: $exifCamera, @@ -47,15 +47,16 @@ struct StatusView: View { } VStack(alignment: .leading) { - NavigationLink(value: RouteurDestinations.userProfile( - accountId: statusViewModel.account.id, - accountDisplayName: statusViewModel.account.displayNameWithoutEmojis, - accountUserName: statusViewModel.account.acct) - ) { - UsernameRow(accountId: statusViewModel.account.id, - accountAvatar: statusViewModel.account.avatar, - accountDisplayName: statusViewModel.account.displayNameWithoutEmojis, - accountUsername: statusViewModel.account.username) + self.reblogInformation() + + UsernameRow(accountId: statusViewModel.account.id, + accountAvatar: statusViewModel.account.avatar, + accountDisplayName: statusViewModel.account.displayNameWithoutEmojis, + accountUsername: statusViewModel.account.acct) + .onTapGesture { + self.routerPath.navigate(to: .userProfile(accountId: statusViewModel.account.id, + accountDisplayName: statusViewModel.account.displayNameWithoutEmojis, + accountUserName: statusViewModel.account.acct)) } MarkdownFormattedText(statusViewModel.content.asMarkdown) @@ -114,15 +115,7 @@ struct StatusView: View { // Get status from API. if let status = try await StatusService.shared.status(withId: self.statusId, and: self.applicationState.accountData) { let statusViewModel = StatusViewModel(status: status) - - // Download images and recalculate exif data. - let allImages = await HomeTimelineService.shared.fetchAllImages(statuses: [status]) - for attachment in statusViewModel.mediaAttachments { - if let data = allImages[attachment.id] { - attachment.set(data: data) - } - } - + self.statusViewModel = statusViewModel self.selectedAttachmentId = statusViewModel.mediaAttachments.first?.id ?? String.empty() self.firstLoadFinished = true @@ -146,6 +139,21 @@ struct StatusView: View { } } + @ViewBuilder func reblogInformation() -> some View { + if let reblogStatus = self.statusViewModel?.reblogStatus { + HStack(alignment: .center, spacing: 4) { + UserAvatar(accountAvatar: reblogStatus.account.avatar, size: .mini) + Text(reblogStatus.account.displayNameWithoutEmojis) + Image(systemName: "paperplane") + .padding(.trailing, 8) + } + .font(.footnote) + .foregroundColor(Color.mainTextColor.opacity(0.4)) + .background(Color.mainTextColor.opacity(0.1)) + .clipShape(Capsule()) + } + } + private func setAttachment(_ attachmentData: AttachmentData) { exifCamera = attachmentData.exifCamera exifExposure = attachmentData.exifExposure diff --git a/Vernissage/Widgets/ImageCarouselPicture.swift b/Vernissage/Widgets/ImageCarouselPicture.swift new file mode 100644 index 0000000..973e42f --- /dev/null +++ b/Vernissage/Widgets/ImageCarouselPicture.swift @@ -0,0 +1,38 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI + +struct ImageCarouselPicture: View { + @ObservedObject public var attachment: AttachmentViewModel + + private let onImageDownloaded: (AttachmentViewModel, Data) -> Void + + init(attachment: AttachmentViewModel, onImageDownloaded: @escaping (_: AttachmentViewModel, _: Data) -> Void) { + self.attachment = attachment + self.onImageDownloaded = onImageDownloaded + } + + var body: some View { + if let data = attachment.data, let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + BlurredImage(blurhash: attachment.blurhash) + .task { + do { + // Download image and recalculate exif data. + if let imageData = try await RemoteFileService.shared.fetchData(url: attachment.url) { + self.onImageDownloaded(attachment, imageData) + } + } catch { + ErrorService.shared.handle(error, message: "Connot download image for status") + } + } + } + } +} diff --git a/Vernissage/Widgets/ImageRow.swift b/Vernissage/Widgets/ImageRow.swift index 0e7ce72..331cd58 100644 --- a/Vernissage/Widgets/ImageRow.swift +++ b/Vernissage/Widgets/ImageRow.swift @@ -10,14 +10,15 @@ struct ImageRow: View { private let status: StatusData private let imageHeight: Double private let imageWidth: Double - private let uiImage:UIImage? private let attachmentData: AttachmentData? + @State private var uiImage:UIImage? + init(statusData: StatusData) { self.status = statusData self.attachmentData = statusData.attachments().first - if let attachmenData = self.attachmentData, let uiImage = UIImage(data: attachmenData.data) { + if let imageData = self.attachmentData?.data, let uiImage = UIImage(data: imageData) { self.uiImage = uiImage let imgHeight = uiImage.size.height @@ -27,6 +28,13 @@ struct ImageRow: View { self.imageWidth = UIScreen.main.bounds.width self.imageHeight = (calculatedHeight > 0 && calculatedHeight < .infinity) ? calculatedHeight : UIScreen.main.bounds.width + } else if let imgWidth = attachmentData?.metaImageWidth, let imgHeight = attachmentData?.metaImageHeight { + let divider = Double(imgWidth) / UIScreen.main.bounds.size.width + let calculatedHeight = Double(imgHeight) / divider + + self.uiImage = nil + self.imageWidth = UIScreen.main.bounds.width + self.imageHeight = (calculatedHeight > 0 && calculatedHeight < .infinity) ? calculatedHeight : UIScreen.main.bounds.width } else { self.uiImage = nil self.imageHeight = UIScreen.main.bounds.width @@ -35,40 +43,48 @@ struct ImageRow: View { } var body: some View { - if let uiImage, let attachmentData { - ZStack { - if self.status.sensitive { - ContentWarning(blurhash: attachmentData.blurhash, spoilerText: self.status.spoilerText) { + if let attachmentData { + if let uiImage { + ZStack { + if self.status.sensitive { + ContentWarning(blurhash: attachmentData.blurhash, spoilerText: self.status.spoilerText) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .transition(.opacity) + } + } else { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) - .transition(.opacity) } - } else { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fit) - } - - if let count = self.status.attachments().count, count > 1 { - BottomRight { - Text("1 / \(count)") - .padding(.horizontal, 6) - .padding(.vertical, 3) - .font(.caption2) - .foregroundColor(.black) - .background(.ultraThinMaterial, in: Capsule()) - }.padding() + + if let count = self.status.attachments().count, count > 1 { + BottomRight { + Text("1 / \(count)") + .padding(.horizontal, 6) + .padding(.vertical, 3) + .font(.caption2) + .foregroundColor(.black) + .background(.ultraThinMaterial, in: Capsule()) + }.padding() + } } + .frame(width: self.imageWidth, height: self.imageHeight) + } else { + BlurredImage(blurhash: attachmentData.blurhash) + .frame(width: self.imageWidth, height: self.imageHeight) + .task { + do { + if let imageData = try await RemoteFileService.shared.fetchData(url: attachmentData.url) { + HomeTimelineService.shared.updateAttachmentDataImage(attachmentData: attachmentData, imageData: imageData) + self.uiImage = UIImage(data: imageData) + } + } catch { + ErrorService.shared.handle(error, message: "Cannot download the image.") + } + } } - .frame(width: self.imageWidth, height: self.imageHeight) } } } - -struct ImageRow_Previews: PreviewProvider { - static var previews: some View { - Text("") - // ImageRow(status: []) - } -} diff --git a/Vernissage/Widgets/ImageRowAsync.swift b/Vernissage/Widgets/ImageRowAsync.swift index 526b669..8f8bad3 100644 --- a/Vernissage/Widgets/ImageRowAsync.swift +++ b/Vernissage/Widgets/ImageRowAsync.swift @@ -91,12 +91,14 @@ struct ImageRowAsync: View { } private func recalculateSizeOfDownloadedImage(imageResponse: ImageResponse) { - if heightWasPrecalculated == false { - let imgHeight = imageResponse.image.size.height - let imgWidth = imageResponse.image.size.width - let calculatedHeight = self.calculateHeight(width: imgWidth, height: imgHeight) - self.imageHeight = (calculatedHeight > 0 && calculatedHeight < .infinity) ? calculatedHeight : UIScreen.main.bounds.width + guard heightWasPrecalculated == false else { + return } + + let imgHeight = imageResponse.image.size.height + let imgWidth = imageResponse.image.size.width + let calculatedHeight = self.calculateHeight(width: imgWidth, height: imgHeight) + self.imageHeight = (calculatedHeight > 0 && calculatedHeight < .infinity) ? calculatedHeight : UIScreen.main.bounds.width } private func calculateHeight(width: Double, height: Double) -> CGFloat { diff --git a/Vernissage/Widgets/ImagesCarousel.swift b/Vernissage/Widgets/ImagesCarousel.swift index 867abee..e2717db 100644 --- a/Vernissage/Widgets/ImagesCarousel.swift +++ b/Vernissage/Widgets/ImagesCarousel.swift @@ -5,11 +5,14 @@ // import SwiftUI +import MastodonKit struct ImagesCarousel: View { @State public var attachments: [AttachmentViewModel] - @State private var height: Double = 0.0 - @State private var selected = String.empty() + @State private var imageHeight: Double + @State private var imageWidth: Double + @State private var selected: String + @State private var heightWasPrecalculated: Bool @Binding public var selectedAttachmentId: String? @Binding public var exifCamera: String? @@ -17,18 +20,64 @@ struct ImagesCarousel: View { @Binding public var exifCreatedDate: String? @Binding public var exifLens: String? + init(attachments: [AttachmentViewModel], + selectedAttachmentId: Binding, + exifCamera: Binding, + exifExposure: Binding, + exifCreatedDate: Binding, + exifLens: Binding + ) { + _selectedAttachmentId = selectedAttachmentId + _exifCamera = exifCamera + _exifExposure = exifExposure + _exifCreatedDate = exifCreatedDate + _exifLens = exifLens + + self.attachments = attachments + self.selected = String.empty() + + var imgHeight = 0.0 + var imgWidth = 0.0 + + for item in attachments { + let attachmentheight = Double((item.meta as? ImageMetadata)?.original?.height ?? 0) + if attachmentheight > imgHeight { + imgHeight = attachmentheight + imgWidth = Double((item.meta as? ImageMetadata)?.original?.width ?? 0) + } + } + + if imgHeight > 0 && imgWidth > 0 { + let divider = Double(imgWidth) / UIScreen.main.bounds.size.width + let calculatedHeight = Double(imgHeight) / divider + + self.imageWidth = UIScreen.main.bounds.width + self.imageHeight = (calculatedHeight > 0 && calculatedHeight < .infinity) ? calculatedHeight : UIScreen.main.bounds.width + self.heightWasPrecalculated = true + } else { + self.imageWidth = UIScreen.main.bounds.width + self.imageHeight = UIScreen.main.bounds.width * 0.75 + self.heightWasPrecalculated = false + } + } + var body: some View { TabView(selection: $selected) { ForEach(attachments, id: \.id) { attachment in - if let data = attachment.data, let image = UIImage(data: data) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .tag(attachment.id) + ImageCarouselPicture(attachment: attachment) { (attachment, imageData) in + withAnimation { + self.recalculateImageHeight(imageData: imageData) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + attachment.set(data: imageData) + } + } + .tag(attachment.id) } } - .frame(height: CGFloat(self.height)) + .frame(height: CGFloat(self.imageHeight)) .tabViewStyle(PageTabViewStyle()) .onChange(of: selected, perform: { index in self.selectedAttachmentId = selected @@ -42,11 +91,14 @@ struct ImagesCarousel: View { }) .onAppear { self.selected = self.attachments.first?.id ?? String.empty() - self.calculateImageHeight() } } - private func calculateImageHeight() { + private func recalculateImageHeight(imageData: Data) { + guard heightWasPrecalculated == false else { + return + } + var imageHeight = 0.0 var imageWidth = 0.0 @@ -59,14 +111,14 @@ struct ImagesCarousel: View { } } + if let image = UIImage(data: imageData) { + if image.size.height > imageHeight { + imageHeight = image.size.height + imageWidth = image.size.width + } + } + let divider = imageWidth / UIScreen.main.bounds.size.width - self.height = imageHeight / divider - } -} - -struct ImagesCarousel_Previews: PreviewProvider { - static var previews: some View { - Text("") - // ImagesCarousel(attachments: [], exifCamera: .constant(""), exifExposure: .constant(""), exifCreatedDate: .constant(""), exifLens: .constant("")) + self.imageHeight = imageHeight / divider } } diff --git a/Vernissage/Widgets/NotificationsView/NotificationRow.swift b/Vernissage/Widgets/NotificationsView/NotificationRow.swift index 08dd505..e249479 100644 --- a/Vernissage/Widgets/NotificationsView/NotificationRow.swift +++ b/Vernissage/Widgets/NotificationsView/NotificationRow.swift @@ -12,10 +12,21 @@ struct NotificationRow: View { @EnvironmentObject var applicationState: ApplicationState @EnvironmentObject var routerPath: RouterPath - @State public var notification: MastodonKit.Notification + @State private var image: SwiftUI.Image? + private var attachment: MediaAttachment? + private var notification: MastodonKit.Notification private let contentWidth = Int(UIScreen.main.bounds.width) - 150 + public init(notification: MastodonKit.Notification) { + self.notification = notification + self.attachment = notification.status?.getAllImageMediaAttachments().first + + if let attachment, let previewUrl = attachment.previewUrl, let imageFromCache = CacheImageService.shared.getImage(for: previewUrl) { + self.image = imageFromCache + } + } + var body: some View { HStack (alignment: .top, spacing: 8) { ZStack { @@ -54,23 +65,27 @@ struct NotificationRow: View { switch self.notification.type { case .favourite, .reblog, .mention, .status, .poll, .update: - if let status = self.notification.status, let statusViewModel = StatusViewModel(status: status) { - HStack(alignment: .top) { - Spacer() - if let attachment = statusViewModel.mediaAttachments.filter({ attachment in - attachment.type == MediaAttachment.MediaAttachmentType.image - }).first { - if let cachedImage = CacheImageService.shared.getImage(for: attachment.url) { - cachedImage - .resizable() - .frame(width: 50, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - EmptyView() - } + HStack(alignment: .top) { + Spacer() + if let attachment { + if let cachedImage = self.image { + cachedImage + .resizable() + .frame(width: 50, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 8)) } else { - EmptyView() + BlurredImage(blurhash: attachment.blurhash) + .frame(width: 50, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .task { + await CacheImageService.shared.downloadImage(url: attachment.previewUrl) + if let previewUrl = attachment.previewUrl, let imageFromCache = CacheImageService.shared.getImage(for: previewUrl) { + self.image = imageFromCache + } + } } + } else { + EmptyView() } } case .follow, .followRequest, .adminSignUp: diff --git a/Vernissage/Widgets/UserAvatar.swift b/Vernissage/Widgets/UserAvatar.swift index 977d39e..3bb97a3 100644 --- a/Vernissage/Widgets/UserAvatar.swift +++ b/Vernissage/Widgets/UserAvatar.swift @@ -11,14 +11,16 @@ struct UserAvatar: View { @EnvironmentObject var applicationState: ApplicationState public enum Size { - case list, comment, profile + case mini, list, comment, profile public var size: CGSize { switch self { - case .list: - return .init(width: 48, height: 48) + case .mini: + return .init(width: 20, height: 20) case .comment: return .init(width: 32, height: 32) + case .list: + return .init(width: 48, height: 48) case .profile: return .init(width: 96, height: 96) } @@ -54,6 +56,9 @@ struct UserAvatar: View { } } .priority(.high) + .task { + await CacheImageService.shared.downloadImage(url: accountAvatar) + } .frame(width: size.size.width, height: size.size.height) } } else {