A lot of fixes.

This commit is contained in:
Marcin Czachursk 2023-01-26 15:10:47 +01:00
parent 0daf234784
commit 50c26e9e4d
26 changed files with 624 additions and 266 deletions

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 = "<group>"; };
F8341F8F295C636C009C8EE6 /* Data+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Exif.swift"; sourceTree = "<group>"; };
F83901A5295D8EC000456AE2 /* LabelIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelIcon.swift; sourceTree = "<group>"; };
F83CBEFA298298A1002972C8 /* ImageCarouselPicture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCarouselPicture.swift; sourceTree = "<group>"; };
F85183232981C2CE001BB950 /* Vernissage20230125-001.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Vernissage20230125-001.xcdatamodel"; sourceTree = "<group>"; };
F857F9FC297D8ED3002C109C /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = "<group>"; };
F85D4970296402DC00751DF7 /* AuthorizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationService.swift; sourceTree = "<group>"; };
F85D4972296406E700751DF7 /* BottomRight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomRight.swift; sourceTree = "<group>"; };
@ -201,6 +204,7 @@
F8984E4C296B648000A2610F /* UIImage+Blurhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Blurhash.swift"; sourceTree = "<group>"; };
F898DE6F2972868A004B4A6A /* String+Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Empty.swift"; sourceTree = "<group>"; };
F898DE7129728CB2004B4A6A /* CommentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentViewModel.swift; sourceTree = "<group>"; };
F8995E432982865C004C191C /* Vernissage20230126-001.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Vernissage20230126-001.xcdatamodel"; sourceTree = "<group>"; };
F8996DEA2971D29D0043EEC6 /* View+Transition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Transition.swift"; sourceTree = "<group>"; };
F89992C8296D6DC7005994BF /* CommentBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentBody.swift; sourceTree = "<group>"; };
F89992CB296D9231005994BF /* StatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusViewModel.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
versionGroupType = wrapper.xcdatamodel;

View File

@ -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

View File

@ -39,7 +39,12 @@ extension StatusData {
@NSManaged public var visibility: String
@NSManaged public var attachmentRelation: Set<AttachmentData>?
@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

View File

@ -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
}
}
}

View File

@ -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 }
}
}

View File

@ -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(

View File

@ -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<String, Data>, 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<String, Data> {
var attachmentUrls: Dictionary<String, URL> = [:]
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

View File

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>Vernissage20230121-001.xcdatamodel</string>
<string>Vernissage20230126-001.xcdatamodel</string>
</dict>
</plist>

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22C65" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AccountData" representedClassName="AccountData" syncable="YES">
<attribute name="accessToken" optional="YES" attributeType="String"/>
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" optional="YES" attributeType="URI"/>
<attribute name="avatarData" optional="YES" attributeType="Binary"/>
<attribute name="clientId" attributeType="String"/>
<attribute name="clientSecret" attributeType="String"/>
<attribute name="clientVapidKey" attributeType="String"/>
<attribute name="createdAt" attributeType="String"/>
<attribute name="displayName" optional="YES" attributeType="String"/>
<attribute name="followersCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" optional="YES" attributeType="URI"/>
<attribute name="id" attributeType="String"/>
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="serverUrl" attributeType="URI"/>
<attribute name="statusesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="username" attributeType="String"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="StatusData" inverseName="pixelfedAccount" inverseEntity="StatusData"/>
</entity>
<entity name="ApplicationSettings" representedClassName="ApplicationSettings" syncable="YES">
<attribute name="avatarShape" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="currentAccount" optional="YES" attributeType="String"/>
<attribute name="theme" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="tintColor" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="YES"/>
</entity>
<entity name="AttachmentData" representedClassName="AttachmentData" syncable="YES">
<attribute name="blurhash" optional="YES" attributeType="String"/>
<attribute name="data" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
<attribute name="exifCamera" optional="YES" attributeType="String"/>
<attribute name="exifCreatedDate" optional="YES" attributeType="String"/>
<attribute name="exifExposure" optional="YES" attributeType="String"/>
<attribute name="exifLens" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="metaImageHeight" optional="YES" attributeType="Integer 32" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="metaImageWidth" optional="YES" attributeType="Integer 32" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="previewUrl" optional="YES" attributeType="URI"/>
<attribute name="remoteUrl" optional="YES" attributeType="URI"/>
<attribute name="statusId" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
<attribute name="type" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
<relationship name="statusRelation" maxCount="1" deletionRule="Cascade" destinationEntity="StatusData" inverseName="attachmentRelation" inverseEntity="StatusData"/>
</entity>
<entity name="StatusData" representedClassName="StatusData" syncable="YES">
<attribute name="accountAvatar" optional="YES" attributeType="URI"/>
<attribute name="accountDisplayName" optional="YES" attributeType="String"/>
<attribute name="accountId" attributeType="String"/>
<attribute name="accountUsername" optional="YES" attributeType="String"/>
<attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="applicationWebsite" optional="YES" attributeType="URI"/>
<attribute name="bookmarked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="String"/>
<attribute name="favourited" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="favouritesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccount" optional="YES" attributeType="String"/>
<attribute name="inReplyToId" optional="YES" attributeType="String"/>
<attribute name="muted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="pinned" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="reblogged" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="rebloggedAccountAvatar" optional="YES" attributeType="URI"/>
<attribute name="rebloggedAccountDisplayName" optional="YES" attributeType="String"/>
<attribute name="rebloggedAccountId" optional="YES" attributeType="String"/>
<attribute name="rebloggedAccountUsername" optional="YES" attributeType="String"/>
<attribute name="rebloggedStatusId" optional="YES" attributeType="String"/>
<attribute name="reblogsCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="repliesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sensitive" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="spoilerText" optional="YES" attributeType="String"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibility" attributeType="String"/>
<relationship name="attachmentRelation" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="AttachmentData" inverseName="statusRelation" inverseEntity="AttachmentData"/>
<relationship name="pixelfedAccount" maxCount="1" deletionRule="No Action" destinationEntity="AccountData" inverseName="statuses" inverseEntity="AccountData"/>
</entity>
</model>

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22C65" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AccountData" representedClassName="AccountData" syncable="YES">
<attribute name="accessToken" optional="YES" attributeType="String"/>
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" optional="YES" attributeType="URI"/>
<attribute name="avatarData" optional="YES" attributeType="Binary"/>
<attribute name="clientId" attributeType="String"/>
<attribute name="clientSecret" attributeType="String"/>
<attribute name="clientVapidKey" attributeType="String"/>
<attribute name="createdAt" attributeType="String"/>
<attribute name="displayName" optional="YES" attributeType="String"/>
<attribute name="followersCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" optional="YES" attributeType="URI"/>
<attribute name="id" attributeType="String"/>
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="serverUrl" attributeType="URI"/>
<attribute name="statusesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="username" attributeType="String"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="StatusData" inverseName="pixelfedAccount" inverseEntity="StatusData"/>
</entity>
<entity name="ApplicationSettings" representedClassName="ApplicationSettings" syncable="YES">
<attribute name="avatarShape" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="currentAccount" optional="YES" attributeType="String"/>
<attribute name="theme" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="tintColor" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="YES"/>
</entity>
<entity name="AttachmentData" representedClassName="AttachmentData" syncable="YES">
<attribute name="blurhash" optional="YES" attributeType="String"/>
<attribute name="data" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
<attribute name="exifCamera" optional="YES" attributeType="String"/>
<attribute name="exifCreatedDate" optional="YES" attributeType="String"/>
<attribute name="exifExposure" optional="YES" attributeType="String"/>
<attribute name="exifLens" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="metaImageHeight" optional="YES" attributeType="Integer 32" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="metaImageWidth" optional="YES" attributeType="Integer 32" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="previewUrl" optional="YES" attributeType="URI"/>
<attribute name="remoteUrl" optional="YES" attributeType="URI"/>
<attribute name="statusId" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
<attribute name="type" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
<relationship name="statusRelation" maxCount="1" deletionRule="Cascade" destinationEntity="StatusData" inverseName="attachmentRelation" inverseEntity="StatusData"/>
</entity>
<entity name="StatusData" representedClassName="StatusData" syncable="YES">
<attribute name="accountAvatar" optional="YES" attributeType="URI"/>
<attribute name="accountDisplayName" optional="YES" attributeType="String"/>
<attribute name="accountId" attributeType="String"/>
<attribute name="accountUsername" optional="YES" attributeType="String"/>
<attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="applicationWebsite" optional="YES" attributeType="URI"/>
<attribute name="bookmarked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="String"/>
<attribute name="favourited" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="favouritesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccount" optional="YES" attributeType="String"/>
<attribute name="inReplyToId" optional="YES" attributeType="String"/>
<attribute name="muted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="pinned" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="reblogged" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="rebloggedAccountAvatar" optional="YES" attributeType="URI"/>
<attribute name="rebloggedAccountDisplayName" optional="YES" attributeType="String"/>
<attribute name="rebloggedAccountId" optional="YES" attributeType="String"/>
<attribute name="rebloggedAccountUsername" optional="YES" attributeType="String"/>
<attribute name="rebloggedStatusId" optional="YES" attributeType="String"/>
<attribute name="reblogsCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="repliesCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sensitive" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="spoilerText" optional="YES" attributeType="String"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibility" attributeType="String"/>
<relationship name="attachmentRelation" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="AttachmentData" inverseName="statusRelation" inverseEntity="AttachmentData"/>
<relationship name="pixelfedAccount" maxCount="1" deletionRule="No Action" destinationEntity="AccountData" inverseName="statuses" inverseEntity="AccountData"/>
</entity>
</model>

View File

@ -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,

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}
}
}
}

View File

@ -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

View File

@ -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")
}
}
}
}
}

View File

@ -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: [])
}
}

View File

@ -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 {

View File

@ -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<String?>,
exifCamera: Binding<String?>,
exifExposure: Binding<String?>,
exifCreatedDate: Binding<String?>,
exifLens: Binding<String?>
) {
_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
}
}

View File

@ -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:

View File

@ -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 {