From 271b4f4e0f3e47147702be66325ec39d517111e6 Mon Sep 17 00:00:00 2001 From: Marcin Czachursk Date: Tue, 3 Jan 2023 14:09:22 +0100 Subject: [PATCH] View refactoring --- Vernissage.xcodeproj/project.pbxproj | 64 ++++++- .../CoreData/AttachmentData+Comperable.swift | 14 ++ .../CoreData/StatusData+Attachments.swift | 13 ++ .../StatusData+CoreDataProperties.swift | 6 - .../MastodonClientAuthenticated+Context.swift | 21 +++ Vernissage/Extensions/String+Date.swift | 104 +++++++++++ Vernissage/Formatters/HTMLFotmattedText.swift | 12 +- Vernissage/SceneDelegate.swift | 1 - .../Services/AuthorizationService.swift | 168 ++++++++++++++++++ Vernissage/Services/TimelineService.swift | 136 ++++++++++++++ Vernissage/VernissageApp.swift | 97 +--------- Vernissage/Views/DetailsView.swift | 110 +++--------- Vernissage/Views/FederatedFeedView.swift | 1 - Vernissage/Views/HomeFeedView.swift | 164 ++--------------- Vernissage/Views/LocalFeedView.swift | 1 - Vernissage/Views/NotificationsView.swift | 1 - Vernissage/Views/SignInView.swift | 79 +------- Vernissage/Widgets/BottomRight.swift | 33 ++++ Vernissage/Widgets/CommentsSection.swift | 80 +++++++++ Vernissage/Widgets/ImageRow.swift | 42 +++++ Vernissage/Widgets/ImagesCarousel.swift | 52 ++++++ Vernissage/Widgets/InteractionRow.swift | 49 +++++ .../{LabelIconView.swift => LabelIcon.swift} | 5 +- .../Widgets/{TagView.swift => Tag.swift} | 5 +- Vernissage/Widgets/UsernameRow.swift | 42 +++++ 25 files changed, 868 insertions(+), 432 deletions(-) create mode 100644 Vernissage/CoreData/AttachmentData+Comperable.swift create mode 100644 Vernissage/CoreData/StatusData+Attachments.swift create mode 100644 Vernissage/Extensions/MastodonClientAuthenticated+Context.swift create mode 100644 Vernissage/Extensions/String+Date.swift create mode 100644 Vernissage/Services/AuthorizationService.swift create mode 100644 Vernissage/Services/TimelineService.swift create mode 100644 Vernissage/Widgets/BottomRight.swift create mode 100644 Vernissage/Widgets/CommentsSection.swift create mode 100644 Vernissage/Widgets/ImageRow.swift create mode 100644 Vernissage/Widgets/ImagesCarousel.swift create mode 100644 Vernissage/Widgets/InteractionRow.swift rename Vernissage/Widgets/{LabelIconView.swift => LabelIcon.swift} (84%) rename Vernissage/Widgets/{TagView.swift => Tag.swift} (93%) create mode 100644 Vernissage/Widgets/UsernameRow.swift diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 2bad2c1..9a9711d 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -15,8 +15,20 @@ F800480A2961EA1900E6868A /* AttachmentDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80048092961EA1900E6868A /* AttachmentDataHandler.swift */; }; F8341F90295C636C009C8EE6 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8341F8F295C636C009C8EE6 /* UIImage.swift */; }; F8341F92295C63BB009C8EE6 /* ImageStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8341F91295C63BB009C8EE6 /* ImageStatus.swift */; }; - F83901A4295D864D00456AE2 /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83901A3295D864D00456AE2 /* TagView.swift */; }; - F83901A6295D8EC000456AE2 /* LabelIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83901A5295D8EC000456AE2 /* LabelIconView.swift */; }; + F83901A4295D864D00456AE2 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83901A3295D864D00456AE2 /* Tag.swift */; }; + F83901A6295D8EC000456AE2 /* LabelIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83901A5295D8EC000456AE2 /* LabelIcon.swift */; }; + F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D4970296402DC00751DF7 /* AuthorizationService.swift */; }; + F85D4973296406E700751DF7 /* BottomRight.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D4972296406E700751DF7 /* BottomRight.swift */; }; + F85D4975296407F100751DF7 /* TimelineService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D4974296407F100751DF7 /* TimelineService.swift */; }; + F85D497729640A5200751DF7 /* ImageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D497629640A5200751DF7 /* ImageRow.swift */; }; + F85D497929640B9D00751DF7 /* ImagesCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D497829640B9D00751DF7 /* ImagesCarousel.swift */; }; + F85D497B29640C8200751DF7 /* UsernameRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D497A29640C8200751DF7 /* UsernameRow.swift */; }; + F85D497D29640D5900751DF7 /* InteractionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D497C29640D5900751DF7 /* InteractionRow.swift */; }; + F85D497F296416C800751DF7 /* CommentsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D497E296416C800751DF7 /* CommentsSection.swift */; }; + F85D4981296417F700751DF7 /* MastodonClientAuthenticated+Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D4980296417F700751DF7 /* MastodonClientAuthenticated+Context.swift */; }; + F85D498329642FAC00751DF7 /* AttachmentData+Comperable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D498229642FAC00751DF7 /* AttachmentData+Comperable.swift */; }; + F85D49852964301800751DF7 /* StatusData+Attachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D49842964301800751DF7 /* StatusData+Attachments.swift */; }; + F85D49872964334100751DF7 /* String+Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D49862964334100751DF7 /* String+Date.swift */; }; F866F6A0296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F69E296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift */; }; F866F6A1296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F69F296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift */; }; F866F6A329604161002E8F88 /* AccountDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A229604161002E8F88 /* AccountDataHandler.swift */; }; @@ -52,8 +64,20 @@ F80048092961EA1900E6868A /* AttachmentDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDataHandler.swift; sourceTree = ""; }; F8341F8F295C636C009C8EE6 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; F8341F91295C63BB009C8EE6 /* ImageStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageStatus.swift; sourceTree = ""; }; - F83901A3295D864D00456AE2 /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; }; - F83901A5295D8EC000456AE2 /* LabelIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelIconView.swift; sourceTree = ""; }; + F83901A3295D864D00456AE2 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; + F83901A5295D8EC000456AE2 /* LabelIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelIcon.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 = ""; }; + F85D4974296407F100751DF7 /* TimelineService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineService.swift; sourceTree = ""; }; + F85D497629640A5200751DF7 /* ImageRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRow.swift; sourceTree = ""; }; + F85D497829640B9D00751DF7 /* ImagesCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesCarousel.swift; sourceTree = ""; }; + F85D497A29640C8200751DF7 /* UsernameRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameRow.swift; sourceTree = ""; }; + F85D497C29640D5900751DF7 /* InteractionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionRow.swift; sourceTree = ""; }; + F85D497E296416C800751DF7 /* CommentsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsSection.swift; sourceTree = ""; }; + F85D4980296417F700751DF7 /* MastodonClientAuthenticated+Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonClientAuthenticated+Context.swift"; sourceTree = ""; }; + F85D498229642FAC00751DF7 /* AttachmentData+Comperable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentData+Comperable.swift"; sourceTree = ""; }; + F85D49842964301800751DF7 /* StatusData+Attachments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusData+Attachments.swift"; sourceTree = ""; }; + F85D49862964334100751DF7 /* String+Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Date.swift"; sourceTree = ""; }; F866F69E296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationSettings+CoreDataClass.swift"; sourceTree = ""; }; F866F69F296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationSettings+CoreDataProperties.swift"; sourceTree = ""; }; F866F6A229604161002E8F88 /* AccountDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDataHandler.swift; sourceTree = ""; }; @@ -111,6 +135,8 @@ isa = PBXGroup; children = ( F8341F8F295C636C009C8EE6 /* UIImage.swift */, + F85D4980296417F700751DF7 /* MastodonClientAuthenticated+Context.swift */, + F85D49862964334100751DF7 /* String+Date.swift */, ); path = Extensions; sourceTree = ""; @@ -130,8 +156,10 @@ children = ( F80047FF2961850500E6868A /* AttachmentData+CoreDataClass.swift */, F80048002961850500E6868A /* AttachmentData+CoreDataProperties.swift */, + F85D498229642FAC00751DF7 /* AttachmentData+Comperable.swift */, F80048012961850500E6868A /* StatusData+CoreDataClass.swift */, F80048022961850500E6868A /* StatusData+CoreDataProperties.swift */, + F85D49842964301800751DF7 /* StatusData+Attachments.swift */, F866F69E296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift */, F866F69F296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift */, F88FAD28295F43B8009B20C9 /* AccountData+CoreDataClass.swift */, @@ -156,8 +184,14 @@ F83901A2295D863B00456AE2 /* Widgets */ = { isa = PBXGroup; children = ( - F83901A3295D864D00456AE2 /* TagView.swift */, - F83901A5295D8EC000456AE2 /* LabelIconView.swift */, + F83901A3295D864D00456AE2 /* Tag.swift */, + F83901A5295D8EC000456AE2 /* LabelIcon.swift */, + F85D4972296406E700751DF7 /* BottomRight.swift */, + F85D497629640A5200751DF7 /* ImageRow.swift */, + F85D497829640B9D00751DF7 /* ImagesCarousel.swift */, + F85D497A29640C8200751DF7 /* UsernameRow.swift */, + F85D497C29640D5900751DF7 /* InteractionRow.swift */, + F85D497E296416C800751DF7 /* CommentsSection.swift */, ); path = Widgets; sourceTree = ""; @@ -210,6 +244,8 @@ isa = PBXGroup; children = ( F88FAD31295F5029009B20C9 /* RemoteFileService.swift */, + F85D4970296402DC00751DF7 /* AuthorizationService.swift */, + F85D4974296407F100751DF7 /* TimelineService.swift */, ); path = Services; sourceTree = ""; @@ -290,30 +326,41 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F85D497729640A5200751DF7 /* ImageRow.swift in Sources */, F80048082961E6DE00E6868A /* StatusDataHandler.swift in Sources */, F866F6A0296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift in Sources */, F88FAD23295F3FC4009B20C9 /* LocalFeedView.swift in Sources */, F88FAD2B295F43B8009B20C9 /* AccountData+CoreDataProperties.swift in Sources */, + F85D4975296407F100751DF7 /* TimelineService.swift in Sources */, F80048062961850500E6868A /* StatusData+CoreDataProperties.swift in Sources */, F88FAD21295F3944009B20C9 /* HomeFeedView.swift in Sources */, F88C2475295C37BB0006098B /* CoreDataHandler.swift in Sources */, F88FAD2A295F43B8009B20C9 /* AccountData+CoreDataClass.swift in Sources */, + F85D49872964334100751DF7 /* String+Date.swift in Sources */, F8341F92295C63BB009C8EE6 /* ImageStatus.swift in Sources */, + F85D498329642FAC00751DF7 /* AttachmentData+Comperable.swift in Sources */, + F85D497B29640C8200751DF7 /* UsernameRow.swift in Sources */, + F85D497929640B9D00751DF7 /* ImagesCarousel.swift in Sources */, F80048052961850500E6868A /* StatusData+CoreDataClass.swift in Sources */, F80048042961850500E6868A /* AttachmentData+CoreDataProperties.swift in Sources */, - F83901A6295D8EC000456AE2 /* LabelIconView.swift in Sources */, + F83901A6295D8EC000456AE2 /* LabelIcon.swift in Sources */, F800480A2961EA1900E6868A /* AttachmentDataHandler.swift in Sources */, F80048032961850500E6868A /* AttachmentData+CoreDataClass.swift in Sources */, F8341F90295C636C009C8EE6 /* UIImage.swift in Sources */, + F85D4981296417F700751DF7 /* MastodonClientAuthenticated+Context.swift in Sources */, F88C246E295C37B80006098B /* MainView.swift in Sources */, F88C2478295C37BB0006098B /* Vernissage.xcdatamodeld in Sources */, F88C2482295C3A4F0006098B /* DetailsView.swift in Sources */, F866F6A329604161002E8F88 /* AccountDataHandler.swift in Sources */, + F85D497F296416C800751DF7 /* CommentsSection.swift in Sources */, F88C2486295C48030006098B /* HTMLFotmattedText.swift in Sources */, F866F6A529604194002E8F88 /* ApplicationSettingsHandler.swift in Sources */, + F85D49852964301800751DF7 /* StatusData+Attachments.swift in Sources */, + F85D497D29640D5900751DF7 /* InteractionRow.swift in Sources */, F866F6A729604629002E8F88 /* SignInView.swift in Sources */, F88C246C295C37B80006098B /* VernissageApp.swift in Sources */, - F83901A4295D864D00456AE2 /* TagView.swift in Sources */, + F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */, + F83901A4295D864D00456AE2 /* Tag.swift in Sources */, F88FAD25295F3FF7009B20C9 /* FederatedFeedView.swift in Sources */, F88FAD32295F5029009B20C9 /* RemoteFileService.swift in Sources */, F88FAD27295F400E009B20C9 /* NotificationsView.swift in Sources */, @@ -321,6 +368,7 @@ F866F6A1296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift in Sources */, F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */, F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */, + F85D4973296406E700751DF7 /* BottomRight.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Vernissage/CoreData/AttachmentData+Comperable.swift b/Vernissage/CoreData/AttachmentData+Comperable.swift new file mode 100644 index 0000000..dea11f4 --- /dev/null +++ b/Vernissage/CoreData/AttachmentData+Comperable.swift @@ -0,0 +1,14 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + + +import Foundation + +extension AttachmentData : Comparable { + public static func < (lhs: AttachmentData, rhs: AttachmentData) -> Bool { + lhs.id < rhs.id + } +} diff --git a/Vernissage/CoreData/StatusData+Attachments.swift b/Vernissage/CoreData/StatusData+Attachments.swift new file mode 100644 index 0000000..e1a504c --- /dev/null +++ b/Vernissage/CoreData/StatusData+Attachments.swift @@ -0,0 +1,13 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation + +extension StatusData { + func attachments() -> [AttachmentData] { + return self.attachmentRelation?.sorted(by: <) ?? [] + } +} diff --git a/Vernissage/CoreData/StatusData+CoreDataProperties.swift b/Vernissage/CoreData/StatusData+CoreDataProperties.swift index 649ed87..52cbbbb 100644 --- a/Vernissage/CoreData/StatusData+CoreDataProperties.swift +++ b/Vernissage/CoreData/StatusData+CoreDataProperties.swift @@ -62,9 +62,3 @@ extension StatusData { extension StatusData : Identifiable { } - -extension StatusData { - func attachments() -> [AttachmentData] { - return Array(self.attachmentRelation ?? []) - } -} diff --git a/Vernissage/Extensions/MastodonClientAuthenticated+Context.swift b/Vernissage/Extensions/MastodonClientAuthenticated+Context.swift new file mode 100644 index 0000000..0f2657d --- /dev/null +++ b/Vernissage/Extensions/MastodonClientAuthenticated+Context.swift @@ -0,0 +1,21 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation +import MastodonSwift + +extension MastodonClientAuthenticated { + func getContext(for statusId: String) async throws -> Context { + let request = try Self.request( + for: baseURL, + target: Mastodon.Statuses.context(statusId), + withBearerToken: token + ) + + let (data, _) = try await urlSession.data(for: request) + return try JSONDecoder().decode(Context.self, from: data) + } +} diff --git a/Vernissage/Extensions/String+Date.swift b/Vernissage/Extensions/String+Date.swift new file mode 100644 index 0000000..b0961fc --- /dev/null +++ b/Vernissage/Extensions/String+Date.swift @@ -0,0 +1,104 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation + +extension String { + public enum DateFormatType { + /// The ISO8601 formatted year "yyyy" i.e. 1997 + case isoYear + + /// The ISO8601 formatted year and month "yyyy-MM" i.e. 1997-07 + case isoYearMonth + + /// The ISO8601 formatted date "yyyy-MM-dd" i.e. 1997-07-16 + case isoDate + + /// The ISO8601 formatted date and time "yyyy-MM-dd'T'HH:mmZ" i.e. 1997-07-16T19:20+01:00 + case isoDateTime + + /// The ISO8601 formatted date, time and sec "yyyy-MM-dd'T'HH:mm:ssZ" i.e. 1997-07-16T19:20:30+01:00 + case isoDateTimeSec + + /// The ISO8601 formatted date, time and millisec "yyyy-MM-dd'T'HH:mm:ss.SSSZ" i.e. 1997-07-16T19:20:30.45+01:00 + case isoDateTimeMilliSec + + /// The dotNet formatted date "/Date(%d%d)/" i.e. "/Date(1268123281843)/" + case dotNet + + /// The RSS formatted date "EEE, d MMM yyyy HH:mm:ss ZZZ" i.e. "Fri, 09 Sep 2011 15:26:08 +0200" + case rss + + /// The Alternative RSS formatted date "d MMM yyyy HH:mm:ss ZZZ" i.e. "09 Sep 2011 15:26:08 +0200" + case altRSS + + /// The http header formatted date "EEE, dd MM yyyy HH:mm:ss ZZZ" i.e. "Tue, 15 Nov 1994 12:45:26 GMT" + case httpHeader + + /// A generic standard format date i.e. "EEE MMM dd HH:mm:ss Z yyyy" + case standard + + /// A custom date format string + case custom(String) + + /// The local formatted date and time "yyyy-MM-dd HH:mm:ss" i.e. 1997-07-16 19:20:00 + case localDateTimeSec + + /// The local formatted date "yyyy-MM-dd" i.e. 1997-07-16 + case localDate + + /// The local formatted time "hh:mm a" i.e. 07:20 am + case localTimeWithNoon + + /// The local formatted date and time "yyyyMMddHHmmss" i.e. 19970716192000 + case localPhotoSave + + case birthDateFormatOne + + case birthDateFormatTwo + + /// + case messageRTetriveFormat + + /// + case emailTimePreview + + var stringFormat:String { + switch self { + //handle iso Time + case .birthDateFormatOne: return "dd/MM/YYYY" + case .birthDateFormatTwo: return "dd-MM-YYYY" + case .isoYear: return "yyyy" + case .isoYearMonth: return "yyyy-MM" + case .isoDate: return "yyyy-MM-dd" + case .isoDateTime: return "yyyy-MM-dd'T'HH:mmZ" + case .isoDateTimeSec: return "yyyy-MM-dd'T'HH:mm:ssZ" + case .isoDateTimeMilliSec: return "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + case .dotNet: return "/Date(%d%f)/" + case .rss: return "EEE, d MMM yyyy HH:mm:ss ZZZ" + case .altRSS: return "d MMM yyyy HH:mm:ss ZZZ" + case .httpHeader: return "EEE, dd MM yyyy HH:mm:ss ZZZ" + case .standard: return "EEE MMM dd HH:mm:ss Z yyyy" + case .custom(let customFormat): return customFormat + + //handle local Time + case .localDateTimeSec: return "yyyy-MM-dd HH:mm:ss" + case .localTimeWithNoon: return "hh:mm a" + case .localDate: return "yyyy-MM-dd" + case .localPhotoSave: return "yyyyMMddHHmmss" + case .messageRTetriveFormat: return "yyyy-MM-dd'T'HH:mm:ssZZZZZ" + case .emailTimePreview: return "dd MMM yyyy, h:mm a" + } + } +} + +func toDate(_ format: DateFormatType = .isoDate) -> Date?{ + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = format.stringFormat + let date = dateFormatter.date(from: self) + return date + } +} diff --git a/Vernissage/Formatters/HTMLFotmattedText.swift b/Vernissage/Formatters/HTMLFotmattedText.swift index 9a10790..ea85cd4 100644 --- a/Vernissage/Formatters/HTMLFotmattedText.swift +++ b/Vernissage/Formatters/HTMLFotmattedText.swift @@ -11,13 +11,17 @@ struct HTMLFormattedText: UIViewRepresentable { let text: String private let textView = UITextView() + private let fontSize: Int + private let width: Int - init(_ content: String) { + init(_ content: String, withFontSize fontSize: Int = 16, andWidth width: Int? = nil) { self.text = content + self.fontSize = fontSize + self.width = width ?? Int(UIScreen.main.bounds.width) - 16 } func makeUIView(context: UIViewRepresentableContext) -> UITextView { - textView.widthAnchor.constraint(equalToConstant:UIScreen.main.bounds.width - 16).isActive = true + textView.widthAnchor.constraint(equalToConstant: CGFloat(self.width)).isActive = true textView.isSelectable = false textView.isUserInteractionEnabled = false textView.translatesAutoresizingMaskIntoConstraints = false @@ -42,12 +46,12 @@ struct HTMLFormattedText: UIViewRepresentable { } let largeAttributes = [ - NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16), + NSAttributedString.Key.font: UIFont.systemFont(ofSize: CGFloat(self.fontSize)), NSAttributedString.Key.foregroundColor: UIColor(Color("mainTextColor")) ] let linkAttributes = [ - NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16), + NSAttributedString.Key.font: UIFont.systemFont(ofSize: CGFloat(self.fontSize)), NSAttributedString.Key.foregroundColor: UIColor(Color("AccentColor")) ] diff --git a/Vernissage/SceneDelegate.swift b/Vernissage/SceneDelegate.swift index c9d2dea..0881ebb 100644 --- a/Vernissage/SceneDelegate.swift +++ b/Vernissage/SceneDelegate.swift @@ -3,7 +3,6 @@ // Copyright © 2022 Marcin Czachurski and the repository contributors. // Licensed under the MIT License. // - import SwiftUI import MastodonSwift diff --git a/Vernissage/Services/AuthorizationService.swift b/Vernissage/Services/AuthorizationService.swift new file mode 100644 index 0000000..e58de7d --- /dev/null +++ b/Vernissage/Services/AuthorizationService.swift @@ -0,0 +1,168 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation +import MastodonSwift + +public class AuthorizationService { + public static let shared = AuthorizationService() + + public func verifyAccount(_ result: @escaping (AccountData?) -> Void) async { + let accountDataHandler = AccountDataHandler() + let currentAccount = accountDataHandler.getCurrentAccountData() + + // When we dont have even one account stored in database then we have to ask user to enter server and sign in. + guard let accountData = currentAccount, let accessToken = accountData.accessToken else { + result(nil) + return + } + + // When we have at least one account then we have to verify access token. + let client = MastodonClient(baseURL: accountData.serverUrl).getAuthenticated(token: accessToken) + + do { + let account = try await client.verifyCredentials() + try await self.updateAccount(accountData: accountData, account: account) + result(accountData) + } catch { + do { + try await self.refreshCredentials(accountData: accountData) + result(accountData) + } catch { + // TODO: show information to the user. + print("Cannot refresh credentials!!!") + } + } + } + + public func signIn(serverAddress: String, _ result: @escaping (AccountData?) -> Void) async throws { + let baseUrl = URL(string: serverAddress)! + let client = MastodonClient(baseURL: baseUrl) + + // Verify address. + let instanceInformation = try await client.readInstanceInformation() + print(instanceInformation) + + // Create application (we will get clientId amd clientSecret). + let oAuthApp = try await client.createApp( + named: "Photofed", + redirectUri: "oauth-vernissage://oauth-callback/mastodon", + scopes: Scopes(["read", "write", "follow", "push"]), + website: baseUrl) + + // Authorize a user (browser, we will get clientCode). + let oAuthSwiftCredential = try await client.authenticate( + app: oAuthApp, + scope: Scopes(["read", "write", "follow", "push"])) + + // Get authenticated client. + let authenticatedClient = client.getAuthenticated(token: oAuthSwiftCredential.oauthToken) + + // Get account information from server. + let account = try await authenticatedClient.verifyCredentials() + + // Create account object in database. + let accountDataHandler = AccountDataHandler() + let accountData = accountDataHandler.createAccountDataEntity() + + accountData.id = account.id + accountData.username = account.username + accountData.acct = account.acct + accountData.displayName = account.displayName + accountData.note = account.note + accountData.url = account.url + accountData.avatar = account.avatar + accountData.header = account.header + accountData.locked = account.locked + accountData.createdAt = account.createdAt + accountData.followersCount = Int32(account.followersCount) + accountData.followingCount = Int32(account.followingCount) + accountData.statusesCount = Int32(account.statusesCount) + + accountData.serverUrl = baseUrl + accountData.clientId = oAuthApp.clientId + accountData.clientSecret = oAuthApp.clientSecret + accountData.clientVapidKey = oAuthApp.vapidKey ?? "" + accountData.accessToken = oAuthSwiftCredential.oauthToken + + // Download avatar image. + if let avatarUrl = account.avatar { + do { + let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) + accountData.avatarData = avatarData + } + catch { + print("Avatar has not been downloaded") + } + } + + // Set newly created account as current. + let applicationSettingsHandler = ApplicationSettingsHandler() + let defaultSettings = applicationSettingsHandler.getDefaultSettings() + defaultSettings.currentAccount = accountData.id + + // Save account data in database and in application state. + CoreDataHandler.shared.save() + + // Return account data. + result(accountData) + } + + private func refreshCredentials(accountData: AccountData) async throws { + let client = MastodonClient(baseURL: accountData.serverUrl) + + // Create application (we will get clientId amd clientSecret). + let oAuthApp = App(clientId: accountData.clientId, clientSecret: accountData.clientSecret) + + // Authorize a user (browser, we will get clientCode). + let oAuthSwiftCredential = try await client.authenticate(app: oAuthApp, scope: Scopes(["read", "write", "follow", "push"])) + + // Get authenticated client. + let authenticatedClient = client.getAuthenticated(token: oAuthSwiftCredential.oauthToken) + + // Get account information from server. + let account = try await authenticatedClient.verifyCredentials() + try await self.updateAccount(accountData: accountData, account: account, accessToken: oAuthSwiftCredential.oauthToken) + } + + private func updateAccount(accountData: AccountData, account: Account, accessToken: String? = nil) async throws { + accountData.username = account.username + accountData.acct = account.acct + accountData.displayName = account.displayName + accountData.note = account.note + accountData.url = account.url + accountData.avatar = account.avatar + accountData.header = account.header + accountData.locked = account.locked + accountData.createdAt = account.createdAt + accountData.followersCount = Int32(account.followersCount) + accountData.followingCount = Int32(account.followingCount) + accountData.statusesCount = Int32(account.statusesCount) + + if accessToken != nil { + accountData.accessToken = accessToken + } + + // Download avatar image. + if let avatarUrl = account.avatar { + do { + let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) + accountData.avatarData = avatarData + } + catch { + print("Avatar has not been downloaded") + } + } + + // We have to be sure that account id is saved as default account. + let applicationSettingsHandler = ApplicationSettingsHandler() + let defaultSettings = applicationSettingsHandler.getDefaultSettings() + defaultSettings.currentAccount = accountData.id + + // Save account data in database and in application state. + CoreDataHandler.shared.save() + } +} diff --git a/Vernissage/Services/TimelineService.swift b/Vernissage/Services/TimelineService.swift new file mode 100644 index 0000000..1a13049 --- /dev/null +++ b/Vernissage/Services/TimelineService.swift @@ -0,0 +1,136 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation +import CoreData +import MastodonSwift + +public class TimelineService { + public static let shared = TimelineService() + + public func onBottomOfList(for accountData: AccountData) async throws { + // Load data from API and operate on CoreData on background context. + let backgroundContext = CoreDataHandler.shared.newBackgroundContext() + + // Get maximimum downloaded stauts id. + let statusDataHandler = StatusDataHandler() + let oldestStatus = statusDataHandler.getMinimumtatus(viewContext: backgroundContext) + + guard let oldestStatus = oldestStatus else { + return + } + + try await self.loadData(for: accountData, on: backgroundContext, maxId: oldestStatus.id) + } + + public func onTopOfList(for accountData: AccountData) async throws { + // Load data from API and operate on CoreData on background context. + let backgroundContext = CoreDataHandler.shared.newBackgroundContext() + + // Get maximimum downloaded stauts id. + let statusDataHandler = StatusDataHandler() + let newestStatus = statusDataHandler.getMaximumStatus(viewContext: backgroundContext) + + guard let newestStatus = newestStatus else { + return + } + + try await self.loadData(for: accountData, on: backgroundContext, minId: newestStatus.id) + } + + public func getComments(for statusId: String, and accountData: AccountData) async throws -> Context { + let client = MastodonClient(baseURL: accountData.serverUrl).getAuthenticated(token: accountData.accessToken ?? "") + return try await client.getContext(for: statusId) + } + + private func loadData(for accountData: AccountData, on backgroundContext: NSManagedObjectContext, minId: String? = nil, maxId: String? = nil) async throws { + guard let accessToken = accountData.accessToken else { + return + } + + // Get maximimum downloaded stauts id. + let attachmentDataHandler = AttachmentDataHandler() + let statusDataHandler = StatusDataHandler() + + // Retrieve statuses from API. + let client = MastodonClient(baseURL: accountData.serverUrl).getAuthenticated(token: accessToken) + let statuses = try await client.getHomeTimeline(maxId: maxId, minId: minId, limit: 40) + + // Download status images and save it into database. + for status in statuses { + + // Save status data in database. + let statusDataEntity = statusDataHandler.createStatusDataEntity(viewContext: backgroundContext) + statusDataEntity.accountAvatar = status.account?.avatar + statusDataEntity.accountDisplayName = status.account?.displayName + statusDataEntity.accountId = status.account!.id + statusDataEntity.accountUsername = status.account!.username + statusDataEntity.applicationName = status.application?.name + statusDataEntity.applicationWebsite = status.application?.website + statusDataEntity.bookmarked = status.bookmarked + statusDataEntity.content = status.content + statusDataEntity.createdAt = status.createdAt + statusDataEntity.favourited = status.favourited + statusDataEntity.favouritesCount = Int32(status.favouritesCount) + statusDataEntity.id = status.id + statusDataEntity.inReplyToAccount = status.inReplyToAccount + statusDataEntity.inReplyToId = status.inReplyToId + statusDataEntity.muted = status.muted + statusDataEntity.pinned = status.pinned + statusDataEntity.reblogged = status.reblogged + statusDataEntity.reblogsCount = Int32(status.reblogsCount) + statusDataEntity.sensitive = status.sensitive + statusDataEntity.spoilerText = status.spoilerText + statusDataEntity.uri = status.uri + statusDataEntity.url = status.url + statusDataEntity.visibility = status.visibility.rawValue + + for attachment in status.mediaAttachments { + let imageData = try await self.fetchImage(attachment: attachment) + + guard let imageData = imageData else { + continue + } + + /* + var exif = image.getExifData() + if let dict = exif as? [String: AnyObject] { + dict.keys.map { key in + print(key) + print(dict[key]) + } + } + */ + + // Save attachment in database. + let attachmentData = attachmentDataHandler.createAttachmnentDataEntity(viewContext: backgroundContext) + attachmentData.id = attachment.id + attachmentData.url = attachment.url + attachmentData.blurhash = attachment.blurhash + attachmentData.previewUrl = attachment.previewUrl + attachmentData.remoteUrl = attachment.remoteUrl + attachmentData.text = attachment.description + attachmentData.type = attachment.type.rawValue + + attachmentData.statusId = statusDataEntity.id + attachmentData.data = imageData + + attachmentData.statusRelation = statusDataEntity + statusDataEntity.addToAttachmentRelation(attachmentData) + } + } + + try backgroundContext.save() + } + + private func fetchImage(attachment: Attachment) async throws -> Data? { + guard let data = try await RemoteFileService.shared.fetchData(url: attachment.url) else { + return nil + } + + return data + } +} diff --git a/Vernissage/VernissageApp.swift b/Vernissage/VernissageApp.swift index e8be64f..7ee3a50 100644 --- a/Vernissage/VernissageApp.swift +++ b/Vernissage/VernissageApp.swift @@ -5,10 +5,9 @@ // import SwiftUI -import MastodonSwift @main -struct VernissageApp: SwiftUI.App { +struct VernissageApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate let coreDataHandler = CoreDataHandler.shared @@ -35,97 +34,19 @@ struct VernissageApp: SwiftUI.App { } } .task { - let accountDataHandler = AccountDataHandler() - let currentAccount = accountDataHandler.getCurrentAccountData() - - // When we dont have even one account stored in database then we have to ask user to enter server and sign in. - guard let accountData = currentAccount, let accessToken = accountData.accessToken else { - self.applicationViewMode = .signIn - return - } - - // When we have at least one account then we have to verify access token. - let client = MastodonClient(baseURL: accountData.serverUrl).getAuthenticated(token: accessToken) - - do { - let account = try await client.verifyCredentials() - try await self.updateAccount(accountData: accountData, account: account) - - self.applicationViewMode = .mainView - self.applicationState.accountData = accountData - } catch { - do { - try await self.refreshCredentials(accountData: accountData) - - self.applicationViewMode = .mainView - self.applicationState.accountData = accountData - } catch { - // TODO: show information to the user. - print("Cannot refresh credentials!!!") + await AuthorizationService.shared.verifyAccount({ accountData in + guard let accountData = accountData else { + self.applicationViewMode = .signIn + return } - } + + self.applicationState.accountData = accountData + self.applicationViewMode = .mainView + }) } .navigationViewStyle(.stack) } } - - private func refreshCredentials(accountData: AccountData) async throws { - let client = MastodonClient(baseURL: accountData.serverUrl) - - // Create application (we will get clientId amd clientSecret). - let oAuthApp = App(clientId: accountData.clientId, clientSecret: accountData.clientSecret) - - // Authorize a user (browser, we will get clientCode). - let oAuthSwiftCredential = try await client.authenticate(app: oAuthApp, scope: Scopes(["read", "write", "follow", "push"])) - - // Get authenticated client. - let authenticatedClient = client.getAuthenticated(token: oAuthSwiftCredential.oauthToken) - - // Get account information from server. - let account = try await authenticatedClient.verifyCredentials() - try await self.updateAccount(accountData: accountData, account: account, accessToken: oAuthSwiftCredential.oauthToken) - - self.applicationState.accountData = accountData - self.applicationViewMode = .mainView - } - - private func updateAccount(accountData: AccountData, account: Account, accessToken: String? = nil) async throws { - accountData.username = account.username - accountData.acct = account.acct - accountData.displayName = account.displayName - accountData.note = account.note - accountData.url = account.url - accountData.avatar = account.avatar - accountData.header = account.header - accountData.locked = account.locked - accountData.createdAt = account.createdAt - accountData.followersCount = Int32(account.followersCount) - accountData.followingCount = Int32(account.followingCount) - accountData.statusesCount = Int32(account.statusesCount) - - if accessToken != nil { - accountData.accessToken = accessToken - } - - // Download avatar image. - if let avatarUrl = account.avatar { - do { - let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) - accountData.avatarData = avatarData - } - catch { - print("Avatar has not been downloaded") - } - } - - // We have to be sure that account id is saved as default account. - let applicationSettingsHandler = ApplicationSettingsHandler() - let defaultSettings = applicationSettingsHandler.getDefaultSettings() - defaultSettings.currentAccount = accountData.id - - // Save account data in database and in application state. - try self.coreDataHandler.container.viewContext.save() - } } class AppDelegate: NSObject, UIApplicationDelegate { diff --git a/Vernissage/Views/DetailsView.swift b/Vernissage/Views/DetailsView.swift index ab6a06d..e64aaed 100644 --- a/Vernissage/Views/DetailsView.swift +++ b/Vernissage/Views/DetailsView.swift @@ -9,120 +9,50 @@ import MastodonSwift import AVFoundation struct DetailsView: View { - @Environment(\.dismiss) private var dismiss - @State public var statusData: StatusData - @State private var height: Double = 0.0 var body: some View { ScrollView { VStack (alignment: .leading) { - TabView { - ForEach(statusData.attachments(), id: \.self) { attachment in - if let image = UIImage(data: attachment.data) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - } - } - } - .frame(height: CGFloat(self.height)) - .tabViewStyle(PageTabViewStyle()) - + ImagesCarousel(attachments: statusData.attachments()) + VStack(alignment: .leading) { - HStack (alignment: .center) { - AsyncImage(url: statusData.accountAvatar) { image in - image - .resizable() - .clipShape(Circle()) - .aspectRatio(contentMode: .fit) - } placeholder: { - Image(systemName: "person.circle") - .resizable() - .foregroundColor(Color("mainTextColor")) - } - .frame(width: 48.0, height: 48.0) - - VStack (alignment: .leading) { - Text(statusData.accountDisplayName ?? statusData.accountUsername) - .foregroundColor(Color("displayNameColor")) - Text("@\(statusData.accountUsername)") - .foregroundColor(Color("lightGrayColor")) - .font(.footnote) - } - .padding(.leading, 8) - } - + UsernameRow(statusData: statusData) HTMLFormattedText(statusData.content) + .padding(.leading, -4) VStack (alignment: .leading) { - LabelIconView(iconName: "camera", value: "SONY ILCE-7M3") - LabelIconView(iconName: "camera.aperture", value: "Viltrox 24mm F1.8 E") - LabelIconView(iconName: "timelapse", value: "24.0 mm, f/1.8, 1/640s, ISO 100") - LabelIconView(iconName: "calendar", value: "2 Oct 2022") + LabelIcon(iconName: "camera", value: "SONY ILCE-7M3") + LabelIcon(iconName: "camera.aperture", value: "Viltrox 24mm F1.8 E") + LabelIcon(iconName: "timelapse", value: "24.0 mm, f/1.8, 1/640s, ISO 100") + LabelIcon(iconName: "calendar", value: "2 Oct 2022") } .foregroundColor(Color("lightGrayColor")) - HStack (alignment: .top) { - TagView { - // Favorite - } content: { - HStack { - Image(systemName: statusData.favourited ? "heart.fill" : "heart") - Text("\(statusData.favouritesCount) likes") - } - } - - TagView { - // Reboost - } content: { - HStack { - Image(systemName: statusData.reblogged ? "arrowshape.turn.up.forward.fill" : "arrowshape.turn.up.forward") - Text("\(statusData.reblogsCount) boosts") - } - } - - Spacer() - - TagView { - // Bookmark - } content: { - Image(systemName: statusData.bookmarked ? "bookmark.fill" : "bookmark") - } + HStack { + Text("Uploaded") + Text(statusData.createdAt.toDate(.isoDateTimeMilliSec) ?? Date(), style: .relative) + .padding(.horizontal, -4) + Text("ago") } - .font(.subheadline) - .foregroundColor(Color("mainTextColor")) + .foregroundColor(Color("lightGrayColor")) + .font(.footnote) + + InteractionRow(statusData: statusData) } .padding(8) + + CommentsSection(statusId: statusData.id) } } .navigationBarTitle("Details") .onAppear { - self.calculateImageHeight() } } - - private func calculateImageHeight() { - var imageHeight = 0.0 - var imageWidth = 0.0 - - for item in statusData.attachments() { - if let image = UIImage(data: item.data) { - 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 DetailsView_Previews: PreviewProvider { static var previews: some View { - Text("") - // DetailsView(current: ImageStatus(id: "123", image: UIImage(), status: Status(from: <#T##Decoder#>))) + DetailsView(statusData: StatusData()) } } diff --git a/Vernissage/Views/FederatedFeedView.swift b/Vernissage/Views/FederatedFeedView.swift index a3227ef..498302d 100644 --- a/Vernissage/Views/FederatedFeedView.swift +++ b/Vernissage/Views/FederatedFeedView.swift @@ -3,7 +3,6 @@ // Copyright © 2022 Marcin Czachurski and the repository contributors. // Licensed under the MIT License. // - import SwiftUI diff --git a/Vernissage/Views/HomeFeedView.swift b/Vernissage/Views/HomeFeedView.swift index 9b0b8da..e40db4b 100644 --- a/Vernissage/Views/HomeFeedView.swift +++ b/Vernissage/Views/HomeFeedView.swift @@ -5,9 +5,6 @@ // import SwiftUI -import MastodonSwift -import UIKit -import CoreData struct HomeFeedView: View { @Environment(\.managedObjectContext) private var viewContext @@ -25,33 +22,10 @@ struct HomeFeedView: View { ScrollView { LazyVGrid(columns: gridColumns) { ForEach(dbStatuses, id: \.self) { item in - NavigationLink(destination: DetailsView(statusData: item)) { - if let attachmenData = item.attachmentRelation?.first, - let uiImage = UIImage(data: attachmenData.data) { - - ZStack { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fit) - - if let count = item.attachmentRelation?.count, count > 1 { - VStack(alignment:.trailing) { - Spacer() - HStack { - Spacer() - Text("1 / \(count)") - .padding(.horizontal, 6) - .padding(.vertical, 3) - .font(.caption2) - .foregroundColor(.black) - .background(.ultraThinMaterial, in: Capsule()) - } - }.padding() - } - } - } else { - Text("Error") - } + NavigationLink(destination: + DetailsView(statusData: item) + .environmentObject(applicationState)) { + ImageRow(attachments: item.attachments()) } } @@ -60,7 +34,9 @@ struct HomeFeedView: View { .onAppear { Task { do { - try await onBottomOfList() + if let accountData = self.applicationState.accountData { + try await TimelineService.shared.onBottomOfList(for: accountData) + } } catch { print("Error", error) } @@ -76,7 +52,9 @@ struct HomeFeedView: View { } .refreshable { do { - try await onTopOfList() + if let accountData = self.applicationState.accountData { + try await TimelineService.shared.onTopOfList(for: accountData) + } } catch { print("Error", error) } @@ -85,7 +63,9 @@ struct HomeFeedView: View { do { if self.dbStatuses.isEmpty { self.showLoading = true - try await onTopOfList() + if let accountData = self.applicationState.accountData { + try await TimelineService.shared.onTopOfList(for: accountData) + } self.showLoading = false } } catch { @@ -94,124 +74,6 @@ struct HomeFeedView: View { } } } - - private func onBottomOfList() async throws { - // Load data from API and operate on CoreData on background context. - let backgroundContext = CoreDataHandler.shared.newBackgroundContext() - - // Get maximimum downloaded stauts id. - let statusDataHandler = StatusDataHandler() - let oldestStatus = statusDataHandler.getMinimumtatus(viewContext: backgroundContext) - - guard let oldestStatus = oldestStatus else { - return - } - - try await self.loadData(on: backgroundContext, maxId: oldestStatus.id) - } - - private func onTopOfList() async throws { - // Load data from API and operate on CoreData on background context. - let backgroundContext = CoreDataHandler.shared.newBackgroundContext() - - // Get maximimum downloaded stauts id. - let statusDataHandler = StatusDataHandler() - let newestStatus = statusDataHandler.getMaximumStatus(viewContext: backgroundContext) - - guard let newestStatus = newestStatus else { - return - } - - try await self.loadData(on: backgroundContext, minId: newestStatus.id) - } - - private func loadData(on backgroundContext: NSManagedObjectContext, minId: String? = nil, maxId: String? = nil) async throws { - guard let accessData = self.applicationState.accountData, let accessToken = accessData.accessToken else { - return - } - - // Get maximimum downloaded stauts id. - let attachmentDataHandler = AttachmentDataHandler() - let statusDataHandler = StatusDataHandler() - - // Retrieve statuses from API. - let client = MastodonClient(baseURL: accessData.serverUrl).getAuthenticated(token: accessToken) - let statuses = try await client.getHomeTimeline(maxId: maxId, minId: minId, limit: 40) - - // Download status images and save it into database. - for status in statuses { - - // Save status data in database. - let statusDataEntity = statusDataHandler.createStatusDataEntity(viewContext: backgroundContext) - statusDataEntity.accountAvatar = status.account?.avatar - statusDataEntity.accountDisplayName = status.account?.displayName - statusDataEntity.accountId = status.account!.id - statusDataEntity.accountUsername = status.account!.username - statusDataEntity.applicationName = status.application?.name - statusDataEntity.applicationWebsite = status.application?.website - statusDataEntity.bookmarked = status.bookmarked - statusDataEntity.content = status.content - statusDataEntity.createdAt = status.createdAt - statusDataEntity.favourited = status.favourited - statusDataEntity.favouritesCount = Int32(status.favouritesCount) - statusDataEntity.id = status.id - statusDataEntity.inReplyToAccount = status.inReplyToAccount - statusDataEntity.inReplyToId = status.inReplyToId - statusDataEntity.muted = status.muted - statusDataEntity.pinned = status.pinned - statusDataEntity.reblogged = status.reblogged - statusDataEntity.reblogsCount = Int32(status.reblogsCount) - statusDataEntity.sensitive = status.sensitive - statusDataEntity.spoilerText = status.spoilerText - statusDataEntity.uri = status.uri - statusDataEntity.url = status.url - statusDataEntity.visibility = status.visibility.rawValue - - for attachment in status.mediaAttachments { - let imageData = try await self.fetchImage(attachment: attachment) - - guard let imageData = imageData else { - continue - } - - /* - var exif = image.getExifData() - if let dict = exif as? [String: AnyObject] { - dict.keys.map { key in - print(key) - print(dict[key]) - } - } - */ - - // Save attachment in database. - let attachmentData = attachmentDataHandler.createAttachmnentDataEntity(viewContext: backgroundContext) - attachmentData.id = attachment.id - attachmentData.url = attachment.url - attachmentData.blurhash = attachment.blurhash - attachmentData.previewUrl = attachment.previewUrl - attachmentData.remoteUrl = attachment.remoteUrl - attachmentData.text = attachment.description - attachmentData.type = attachment.type.rawValue - - attachmentData.statusId = statusDataEntity.id - attachmentData.data = imageData - - attachmentData.statusRelation = statusDataEntity - statusDataEntity.addToAttachmentRelation(attachmentData) - } - } - - try backgroundContext.save() - } - - public func fetchImage(attachment: Attachment) async throws -> Data? { - guard let data = try await RemoteFileService.shared.fetchData(url: attachment.url) else { - return nil - } - - return data - } } struct HomeFeedView_Previews: PreviewProvider { diff --git a/Vernissage/Views/LocalFeedView.swift b/Vernissage/Views/LocalFeedView.swift index 6623c2d..78e355b 100644 --- a/Vernissage/Views/LocalFeedView.swift +++ b/Vernissage/Views/LocalFeedView.swift @@ -3,7 +3,6 @@ // Copyright © 2022 Marcin Czachurski and the repository contributors. // Licensed under the MIT License. // - import SwiftUI diff --git a/Vernissage/Views/NotificationsView.swift b/Vernissage/Views/NotificationsView.swift index 3ad2fa3..a3730c8 100644 --- a/Vernissage/Views/NotificationsView.swift +++ b/Vernissage/Views/NotificationsView.swift @@ -3,7 +3,6 @@ // Copyright © 2022 Marcin Czachurski and the repository contributors. // Licensed under the MIT License. // - import SwiftUI diff --git a/Vernissage/Views/SignInView.swift b/Vernissage/Views/SignInView.swift index 067cd5e..ed2cc01 100644 --- a/Vernissage/Views/SignInView.swift +++ b/Vernissage/Views/SignInView.swift @@ -5,7 +5,6 @@ // import SwiftUI -import MastodonSwift struct SignInView: View { @Environment(\.managedObjectContext) private var viewContext @@ -29,7 +28,10 @@ struct SignInView: View { Button("Go") { Task { - try await self.signIn() + try await AuthorizationService.shared.signIn(serverAddress: serverAddress, { accountData in + self.applicationState.accountData = accountData + onSignInStateChenge(.mainView) + }) } } } @@ -38,79 +40,6 @@ struct SignInView: View { .navigationBarTitle("Sign in to Pixelfed") .navigationBarTitleDisplayMode(.inline) } - - private func signIn() async throws { - let baseUrl = URL(string: serverAddress)! - let client = MastodonClient(baseURL: baseUrl) - - // Verify address. - let instanceInformation = try await client.readInstanceInformation() - print(instanceInformation) - - // Create application (we will get clientId amd clientSecret). - let oAuthApp = try await client.createApp( - named: "Photofed", - redirectUri: "oauth-vernissage://oauth-callback/mastodon", - scopes: Scopes(["read", "write", "follow", "push"]), - website: baseUrl) - - // Authorize a user (browser, we will get clientCode). - let oAuthSwiftCredential = try await client.authenticate( - app: oAuthApp, - scope: Scopes(["read", "write", "follow", "push"])) - - // Get authenticated client. - let authenticatedClient = client.getAuthenticated(token: oAuthSwiftCredential.oauthToken) - - // Get account information from server. - let account = try await authenticatedClient.verifyCredentials() - - // Create account object in database. - let accountDataHandler = AccountDataHandler() - let accountData = accountDataHandler.createAccountDataEntity() - - accountData.id = account.id - accountData.username = account.username - accountData.acct = account.acct - accountData.displayName = account.displayName - accountData.note = account.note - accountData.url = account.url - accountData.avatar = account.avatar - accountData.header = account.header - accountData.locked = account.locked - accountData.createdAt = account.createdAt - accountData.followersCount = Int32(account.followersCount) - accountData.followingCount = Int32(account.followingCount) - accountData.statusesCount = Int32(account.statusesCount) - - accountData.serverUrl = baseUrl - accountData.clientId = oAuthApp.clientId - accountData.clientSecret = oAuthApp.clientSecret - accountData.clientVapidKey = oAuthApp.vapidKey ?? "" - accountData.accessToken = oAuthSwiftCredential.oauthToken - - // Download avatar image. - if let avatarUrl = account.avatar { - do { - let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) - accountData.avatarData = avatarData - } - catch { - print("Avatar has not been downloaded") - } - } - - // Set newly created account as current. - let applicationSettingsHandler = ApplicationSettingsHandler() - let defaultSettings = applicationSettingsHandler.getDefaultSettings() - defaultSettings.currentAccount = accountData.id - - // Save account data in database and in application state. - try self.viewContext.save() - - self.applicationState.accountData = accountData - self.onSignInStateChenge(.mainView) - } } struct SignInView_Previews: PreviewProvider { diff --git a/Vernissage/Widgets/BottomRight.swift b/Vernissage/Widgets/BottomRight.swift new file mode 100644 index 0000000..c055d89 --- /dev/null +++ b/Vernissage/Widgets/BottomRight.swift @@ -0,0 +1,33 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI + +struct BottomRight: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack(alignment:.trailing) { + Spacer() + HStack { + Spacer() + content + } + } + } +} + +struct BottomRight_Previews: PreviewProvider { + static var previews: some View { + BottomRight { + Text("1/2") + } + } +} diff --git a/Vernissage/Widgets/CommentsSection.swift b/Vernissage/Widgets/CommentsSection.swift new file mode 100644 index 0000000..cf1eca8 --- /dev/null +++ b/Vernissage/Widgets/CommentsSection.swift @@ -0,0 +1,80 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI +import MastodonSwift + +struct CommentsSection: View { + @EnvironmentObject var applicationState: ApplicationState + + @State public var statusId: String + @State public var withDivider = true + @State private var context: Context? + + private let contentWidth = Int(UIScreen.main.bounds.width) - 50 + + var body: some View { + VStack { + if let context = context { + ForEach(context.descendants, id: \.id) { status in + HStack (alignment: .top) { + AsyncImage(url: status.account?.avatar) { image in + image + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fit) + } placeholder: { + Image(systemName: "person.circle") + .resizable() + .foregroundColor(Color("mainTextColor")) + } + .frame(width: 32.0, height: 32.0) + + VStack (alignment: .leading) { + HStack (alignment: .top) { + Text(status.account?.displayName ?? status.account?.username ?? "") + .foregroundColor(Color("displayNameColor")) + .font(.footnote) + .fontWeight(.bold) + Text("@\(status.account?.username ?? "")") + .foregroundColor(Color("lightGrayColor")) + .font(.footnote) + } + .padding(.bottom, -10) + + HTMLFormattedText(status.content, withFontSize: 14, andWidth: contentWidth) + .padding(.leading, -4) + } + } + .padding(.horizontal, 8) + + CommentsSection(statusId: status.id, withDivider: false) + + if withDivider { + Rectangle() + .size(width: UIScreen.main.bounds.width, height: 4) + .fill(Color("mainTextColor")) + .opacity(0.05) + } + } + } + }.task { + do { + if let accountData = applicationState.accountData { + self.context = try await TimelineService.shared.getComments(for: statusId, and: accountData) + } + } catch { + print("Error \(error.localizedDescription)") + } + } + } +} + +struct CommentsSection_Previews: PreviewProvider { + static var previews: some View { + CommentsSection(statusId: "", withDivider: true) + } +} diff --git a/Vernissage/Widgets/ImageRow.swift b/Vernissage/Widgets/ImageRow.swift new file mode 100644 index 0000000..6992ad7 --- /dev/null +++ b/Vernissage/Widgets/ImageRow.swift @@ -0,0 +1,42 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI + +struct ImageRow: View { + @State public var attachments: [AttachmentData] + + var body: some View { + if let attachmenData = attachments.first, + let uiImage = UIImage(data: attachmenData.data) { + + ZStack { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + + if let count = attachments.count, count > 1 { + BottomRight { + Text("1 / \(count)") + .padding(.horizontal, 6) + .padding(.vertical, 3) + .font(.caption2) + .foregroundColor(.black) + .background(.ultraThinMaterial, in: Capsule()) + }.padding() + } + } + } else { + Text("Error") + } + } +} + +struct ImageRow_Previews: PreviewProvider { + static var previews: some View { + ImageRow(attachments: []) + } +} diff --git a/Vernissage/Widgets/ImagesCarousel.swift b/Vernissage/Widgets/ImagesCarousel.swift new file mode 100644 index 0000000..d384dae --- /dev/null +++ b/Vernissage/Widgets/ImagesCarousel.swift @@ -0,0 +1,52 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI + +struct ImagesCarousel: View { + @State public var attachments: [AttachmentData] + @State private var height: Double = 0.0 + + var body: some View { + TabView { + ForEach(attachments, id: \.self) { attachment in + if let image = UIImage(data: attachment.data) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } + } + } + .frame(height: CGFloat(self.height)) + .tabViewStyle(PageTabViewStyle()) + .onAppear { + self.calculateImageHeight() + } + } + + private func calculateImageHeight() { + var imageHeight = 0.0 + var imageWidth = 0.0 + + for item in attachments { + if let image = UIImage(data: item.data) { + 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 { + ImagesCarousel(attachments: []) + } +} diff --git a/Vernissage/Widgets/InteractionRow.swift b/Vernissage/Widgets/InteractionRow.swift new file mode 100644 index 0000000..a60a000 --- /dev/null +++ b/Vernissage/Widgets/InteractionRow.swift @@ -0,0 +1,49 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI + +struct InteractionRow: View { + @State public var statusData: StatusData + + var body: some View { + HStack (alignment: .top) { + Tag { + // Favorite + } content: { + HStack { + Image(systemName: statusData.favourited ? "heart.fill" : "heart") + Text("\(statusData.favouritesCount) likes") + } + } + + Tag { + // Reboost + } content: { + HStack { + Image(systemName: statusData.reblogged ? "arrowshape.turn.up.forward.fill" : "arrowshape.turn.up.forward") + Text("\(statusData.reblogsCount) boosts") + } + } + + Spacer() + + Tag { + // Bookmark + } content: { + Image(systemName: statusData.bookmarked ? "bookmark.fill" : "bookmark") + } + } + .font(.subheadline) + .foregroundColor(Color("mainTextColor")) + } +} + +struct InteractionRow_Previews: PreviewProvider { + static var previews: some View { + InteractionRow(statusData: StatusData()) + } +} diff --git a/Vernissage/Widgets/LabelIconView.swift b/Vernissage/Widgets/LabelIcon.swift similarity index 84% rename from Vernissage/Widgets/LabelIconView.swift rename to Vernissage/Widgets/LabelIcon.swift index 7bcae39..4deb48d 100644 --- a/Vernissage/Widgets/LabelIconView.swift +++ b/Vernissage/Widgets/LabelIcon.swift @@ -3,11 +3,10 @@ // Copyright © 2022 Marcin Czachurski and the repository contributors. // Licensed under the MIT License. // - import SwiftUI -struct LabelIconView: View { +struct LabelIcon: View { let iconName: String let value: String @@ -24,6 +23,6 @@ struct LabelIconView: View { struct LabelIconView_Previews: PreviewProvider { static var previews: some View { - LabelIconView(iconName: "camera", value: "Sony A7") + LabelIcon(iconName: "camera", value: "Sony A7") } } diff --git a/Vernissage/Widgets/TagView.swift b/Vernissage/Widgets/Tag.swift similarity index 93% rename from Vernissage/Widgets/TagView.swift rename to Vernissage/Widgets/Tag.swift index a2ed4a7..5e76ca4 100644 --- a/Vernissage/Widgets/TagView.swift +++ b/Vernissage/Widgets/Tag.swift @@ -3,11 +3,10 @@ // Copyright © 2022 Marcin Czachurski and the repository contributors. // Licensed under the MIT License. // - import SwiftUI -struct TagView: View { +struct Tag: View { let content: Content let action: () -> Void @@ -31,7 +30,7 @@ struct TagView: View { struct TagView_Previews: PreviewProvider { static var previews: some View { - TagView { + Tag { } content: { HStack { diff --git a/Vernissage/Widgets/UsernameRow.swift b/Vernissage/Widgets/UsernameRow.swift new file mode 100644 index 0000000..f730a9f --- /dev/null +++ b/Vernissage/Widgets/UsernameRow.swift @@ -0,0 +1,42 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI + +struct UsernameRow: View { + @State public var statusData: StatusData + + var body: some View { + HStack (alignment: .center) { + AsyncImage(url: statusData.accountAvatar) { image in + image + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fit) + } placeholder: { + Image(systemName: "person.circle") + .resizable() + .foregroundColor(Color("mainTextColor")) + } + .frame(width: 48.0, height: 48.0) + + VStack (alignment: .leading) { + Text(statusData.accountDisplayName ?? statusData.accountUsername) + .foregroundColor(Color("displayNameColor")) + Text("@\(statusData.accountUsername)") + .foregroundColor(Color("lightGrayColor")) + .font(.footnote) + } + .padding(.leading, 8) + } + } +} + +struct UsernameRow_Previews: PreviewProvider { + static var previews: some View { + UsernameRow(statusData: StatusData()) + } +}