diff --git a/README.md b/README.md new file mode 100644 index 0000000..a19cf5a --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Vernissage + +## Font + +Font used in the application is: Fleur De Leah +https://fonts.google.com/specimen/Fleur+De+Leah?preview.text=Vernissage%20for&preview.text_type=custom diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 9e68fd8..4f50717 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -50,6 +50,8 @@ F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A929605AFA002E8F88 /* SceneDelegate.swift */; }; F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6AD29606367002E8F88 /* ApplicationViewMode.swift */; }; F866F6B729608467002E8F88 /* MastodonSwift in Frameworks */ = {isa = PBXBuildFile; productRef = F866F6B629608467002E8F88 /* MastodonSwift */; }; + F88ABD9229686F1C004EF61E /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88ABD9129686F1C004EF61E /* MemoryCache.swift */; }; + F88ABD9429687CA4004EF61E /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88ABD9329687CA4004EF61E /* ComposeView.swift */; }; F88C246C295C37B80006098B /* VernissageApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C246B295C37B80006098B /* VernissageApp.swift */; }; F88C246E295C37B80006098B /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C246D295C37B80006098B /* MainView.swift */; }; F88C2470295C37BB0006098B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F88C246F295C37BB0006098B /* Assets.xcassets */; }; @@ -116,6 +118,9 @@ F866F6A829604FFF002E8F88 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; F866F6A929605AFA002E8F88 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; F866F6AD29606367002E8F88 /* ApplicationViewMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationViewMode.swift; sourceTree = ""; }; + F88ABD9129686F1C004EF61E /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = ""; }; + F88ABD9329687CA4004EF61E /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; + F88ABD9529687D4D004EF61E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; F88C2468295C37B80006098B /* Vernissage.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Vernissage.app; sourceTree = BUILT_PRODUCTS_DIR; }; F88C246B295C37B80006098B /* VernissageApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VernissageApp.swift; sourceTree = ""; }; F88C246D295C37B80006098B /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; @@ -179,6 +184,7 @@ F85DBF8E296732E20069BF89 /* FollowersView.swift */, F85DBF902967385F0069BF89 /* FollowingView.swift */, F897978E29684BCB00B22335 /* LoadingView.swift */, + F88ABD9329687CA4004EF61E /* ComposeView.swift */, ); path = Views; sourceTree = ""; @@ -256,9 +262,18 @@ path = Widgets; sourceTree = ""; }; + F88ABD9029686F00004EF61E /* Cache */ = { + isa = PBXGroup; + children = ( + F88ABD9129686F1C004EF61E /* MemoryCache.swift */, + ); + path = Cache; + sourceTree = ""; + }; F88C245F295C37B80006098B = { isa = PBXGroup; children = ( + F88ABD9529687D4D004EF61E /* README.md */, F88C246A295C37B80006098B /* Vernissage */, F88C2469295C37B80006098B /* Products */, ); @@ -276,6 +291,7 @@ isa = PBXGroup; children = ( F866F6A829604FFF002E8F88 /* Info.plist */, + F88ABD9029686F00004EF61E /* Cache */, F897978B2968367E00B22335 /* Haptics */, F8210DE82966E4D8001D9973 /* Modifiers */, F88FAD30295F5010009B20C9 /* Services */, @@ -446,9 +462,11 @@ F85D497F296416C800751DF7 /* CommentsSection.swift in Sources */, F88C2486295C48030006098B /* HTMLFotmattedText.swift in Sources */, F866F6A529604194002E8F88 /* ApplicationSettingsHandler.swift in Sources */, + F88ABD9229686F1C004EF61E /* MemoryCache.swift in Sources */, F8210DE32966D256001D9973 /* Status+StatusData.swift in Sources */, F85D49852964301800751DF7 /* StatusData+Attachments.swift in Sources */, F8210DE72966E1D1001D9973 /* Color+Assets.swift in Sources */, + F88ABD9429687CA4004EF61E /* ComposeView.swift in Sources */, F85D497D29640D5900751DF7 /* InteractionRow.swift in Sources */, F866F6A729604629002E8F88 /* SignInView.swift in Sources */, F88C246C295C37B80006098B /* VernissageApp.swift in Sources */, diff --git a/Vernissage/Cache/MemoryCache.swift b/Vernissage/Cache/MemoryCache.swift new file mode 100644 index 0000000..a7f6a8f --- /dev/null +++ b/Vernissage/Cache/MemoryCache.swift @@ -0,0 +1,90 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation + +/// Memory cache based on article: https://www.swiftbysundell.com/articles/caching-in-swift/ +final class MemoryCache { + private let wrapped = NSCache() + private let dateProvider: () -> Date + private let entryLifetime: TimeInterval + + init(dateProvider: @escaping () -> Date = Date.init, + entryLifetime: TimeInterval = 12 * 60 * 60) { + self.dateProvider = dateProvider + self.entryLifetime = entryLifetime + } + + func insert(_ value: Value, forKey key: Key) { + let date = dateProvider().addingTimeInterval(entryLifetime) + let entry = Entry(value: value, expirationDate: date) + wrapped.setObject(entry, forKey: WrappedKey(key)) + } + + func value(forKey key: Key) -> Value? { + guard let entry = wrapped.object(forKey: WrappedKey(key)) else { + return nil + } + + guard dateProvider() < entry.expirationDate else { + // Discard values that have expired + removeValue(forKey: key) + return nil + } + + return entry.value + } + + func removeValue(forKey key: Key) { + wrapped.removeObject(forKey: WrappedKey(key)) + } +} + +private extension MemoryCache { + final class WrappedKey: NSObject { + let key: Key + + init(_ key: Key) { self.key = key } + + override var hash: Int { return key.hashValue } + + override func isEqual(_ object: Any?) -> Bool { + guard let value = object as? WrappedKey else { + return false + } + + return value.key == key + } + } +} + +private extension MemoryCache { + final class Entry { + let value: Value + let expirationDate: Date + + init(value: Value, expirationDate: Date) { + self.value = value + self.expirationDate = expirationDate + } + } +} + +extension MemoryCache { + subscript(key: Key) -> Value? { + get { return value(forKey: key) } + set { + guard let value = newValue else { + // If nil was assigned using our subscript, + // then we remove any value for that key: + removeValue(forKey: key) + return + } + + insert(value, forKey: key) + } + } +} diff --git a/Vernissage/Services/CacheAvatarService.swift b/Vernissage/Services/CacheAvatarService.swift index 8faad89..e875de6 100644 --- a/Vernissage/Services/CacheAvatarService.swift +++ b/Vernissage/Services/CacheAvatarService.swift @@ -12,11 +12,11 @@ public class CacheAvatarService { public static let shared = CacheAvatarService() private init() { } - private var cache: Dictionary = [:] + private var memoryChartData = MemoryCache(entryLifetime: 5 * 60) func addImage(for id: String, data: Data) { if let uiImage = UIImage(data: data) { - self.cache[id] = uiImage + self.memoryChartData[id] = uiImage } } @@ -25,6 +25,10 @@ public class CacheAvatarService { return } + if memoryChartData[accountId] != nil { + return + } + do { let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) if let avatarData { @@ -36,6 +40,6 @@ public class CacheAvatarService { } func getImage(for id: String) -> UIImage? { - return self.cache[id] + return self.memoryChartData[id] } } diff --git a/Vernissage/Views/ComposeView.swift b/Vernissage/Views/ComposeView.swift new file mode 100644 index 0000000..1982a8d --- /dev/null +++ b/Vernissage/Views/ComposeView.swift @@ -0,0 +1,46 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI + +struct ComposeView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + VStack{ + Text("Composen message placeholder") + .font(.caption2) + .foregroundColor(.mainTextColor) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + dismiss() + } label: { + Text("Publish") + .foregroundColor(.white) + } + .buttonStyle(.borderedProminent) + .tint(.accentColor) + } + + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", role: .cancel) { + dismiss() + } + } + } + .navigationBarTitle(Text("Compose"), displayMode: .inline) + } + } +} + +struct ComposeView_Previews: PreviewProvider { + static var previews: some View { + ComposeView() + } +} diff --git a/Vernissage/Views/FollowersView.swift b/Vernissage/Views/FollowersView.swift index 85e2602..8fc537a 100644 --- a/Vernissage/Views/FollowersView.swift +++ b/Vernissage/Views/FollowersView.swift @@ -31,18 +31,15 @@ struct FollowersView: View { } } - if allItemsLoaded == false && firstLoadFinished { - HStack(alignment: .center) { - Spacer() - LoadingIndicator() - .onAppear { - Task { - self.page = self.page + 1 - await self.loadAccounts(page: self.page) - } + if allItemsLoaded == false && firstLoadFinished == true { + LoadingIndicator() + .onAppear { + Task { + self.page = self.page + 1 + await self.loadAccounts(page: self.page) } - Spacer() - } + } + .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) } }.overlay { if firstLoadFinished == false { @@ -68,7 +65,7 @@ struct FollowersView: View { andContext: self.applicationState.accountData, page: page) - if accountsFromApi.isEmpty { + if accountsFromApi.isEmpty || accountsFromApi.count < 10 { self.allItemsLoaded = true return } diff --git a/Vernissage/Views/FollowingView.swift b/Vernissage/Views/FollowingView.swift index dd173a8..0838cc9 100644 --- a/Vernissage/Views/FollowingView.swift +++ b/Vernissage/Views/FollowingView.swift @@ -31,18 +31,15 @@ struct FollowingView: View { } } - if allItemsLoaded == false && firstLoadFinished { - HStack(alignment: .center) { - Spacer() - LoadingIndicator() - .onAppear { - Task { - self.page = self.page + 1 - await self.loadAccounts(page: self.page) - } + if allItemsLoaded == false && firstLoadFinished == true { + LoadingIndicator() + .onAppear { + Task { + self.page = self.page + 1 + await self.loadAccounts(page: self.page) } - Spacer() - } + } + .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) } }.overlay { if firstLoadFinished == false { @@ -68,7 +65,7 @@ struct FollowingView: View { andContext: self.applicationState.accountData, page: page) - if accountsFromApi.isEmpty { + if accountsFromApi.isEmpty || accountsFromApi.count < 10 { self.allItemsLoaded = true return } diff --git a/Vernissage/Views/StatusView.swift b/Vernissage/Views/StatusView.swift index 2f9e7c4..77163a4 100644 --- a/Vernissage/Views/StatusView.swift +++ b/Vernissage/Views/StatusView.swift @@ -12,6 +12,7 @@ struct StatusView: View { @EnvironmentObject var applicationState: ApplicationState @State var statusId: String + @State private var showCompose = false @State private var statusData: StatusData? var body: some View { @@ -54,12 +55,16 @@ struct StatusView: View { .foregroundColor(.lightGrayColor) .font(.footnote) - InteractionRow(statusData: statusData) - .padding(8) + InteractionRow(statusData: statusData) { context in + self.showCompose.toggle() + } + .padding(8) } .padding(8) - CommentsSection(statusId: statusData.id) + CommentsSection(statusId: statusData.id) { context in + self.showCompose.toggle() + } } } else { VStack (alignment: .leading) { @@ -89,6 +94,9 @@ struct StatusView: View { } } .navigationBarTitle("Details") + .sheet(isPresented: $showCompose, content: { + ComposeView() + }) .onAppear { Task { do { diff --git a/Vernissage/Widgets/CommentsSection.swift b/Vernissage/Widgets/CommentsSection.swift index 8f006b3..a1d14a6 100644 --- a/Vernissage/Widgets/CommentsSection.swift +++ b/Vernissage/Widgets/CommentsSection.swift @@ -14,6 +14,8 @@ struct CommentsSection: View { @State public var withDivider = true @State private var context: Context? + var onNewStatus: (_ context: Status) -> Void? + private let contentWidth = Int(UIScreen.main.bounds.width) - 50 var body: some View { @@ -52,20 +54,40 @@ struct CommentsSection: View { VStack (alignment: .leading) { HStack (alignment: .top) { - Text(status.account?.displayName ?? status.account?.acct ?? "") + Text(status.account?.displayName ?? status.account?.acct ?? status.account?.username ?? "") .foregroundColor(.mainTextColor) .font(.footnote) .fontWeight(.bold) Spacer() + + Button { + HapticService.shared.touch() + onNewStatus(status) + } label: { + Image(systemName: "message") + .foregroundColor(.lightGrayColor) + .font(.footnote) + } + .padding(.trailing, 8) + + Button { + HapticService.shared.touch() + // TODO: favorite + } label: { + Image(systemName: status.favourited ? "hand.thumbsup.fill" : "hand.thumbsup") + .foregroundColor(.lightGrayColor) + .font(.footnote) + } + .padding(.trailing, 8) Text(status.createdAt.toRelative(.isoDateTimeMilliSec)) - .foregroundColor(.lightGrayColor.opacity(0.5)) + .foregroundColor(.lightGrayColor) .font(.footnote) } HTMLFormattedText(status.content, withFontSize: 14, andWidth: contentWidth) - .padding(.top, -16) + .padding(.top, -10) .padding(.leading, -4) if status.mediaAttachments.count > 0 { @@ -97,7 +119,9 @@ struct CommentsSection: View { .padding(.horizontal, 8) .padding(.bottom, 8) - CommentsSection(statusId: status.id, withDivider: false) + CommentsSection(statusId: status.id, withDivider: false) { context in + onNewStatus(context) + } } } } @@ -117,6 +141,6 @@ struct CommentsSection: View { struct CommentsSection_Previews: PreviewProvider { static var previews: some View { - CommentsSection(statusId: "", withDivider: true) + CommentsSection(statusId: "", withDivider: true) { context in } } } diff --git a/Vernissage/Widgets/InteractionRow.swift b/Vernissage/Widgets/InteractionRow.swift index 887611a..272d859 100644 --- a/Vernissage/Widgets/InteractionRow.swift +++ b/Vernissage/Widgets/InteractionRow.swift @@ -10,11 +10,13 @@ struct InteractionRow: View { @EnvironmentObject var applicationState: ApplicationState @ObservedObject public var statusData: StatusData + var onNewStatus: (_ context: StatusData) -> Void? + var body: some View { HStack (alignment: .top) { Button { - // TODO: Reply. HapticService.shared.touch() + onNewStatus(statusData) } label: { HStack(alignment: .center) { Image(systemName: "message") @@ -120,7 +122,7 @@ struct InteractionRow: View { struct InteractionRow_Previews: PreviewProvider { static var previews: some View { - InteractionRow(statusData: PreviewData.getStatus()) + InteractionRow(statusData: PreviewData.getStatus()) { context in } .previewLayout(.fixed(width: 300, height: 70)) } } diff --git a/Vernissage/Widgets/LoadingIndicator.swift b/Vernissage/Widgets/LoadingIndicator.swift index 0b79574..4dc492d 100644 --- a/Vernissage/Widgets/LoadingIndicator.swift +++ b/Vernissage/Widgets/LoadingIndicator.swift @@ -9,9 +9,13 @@ import SwiftUI struct LoadingIndicator: View { var body: some View { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .tint(.mainTextColor) + ProgressView { + Text("Loading...") + .foregroundColor(.mainTextColor) + .font(.caption2) + } + .progressViewStyle(CircularProgressViewStyle()) + .tint(.mainTextColor) } }