diff --git a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5b4efd2c..035381c5 100644 --- a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke", "state" : { - "revision" : "311016d972aa751ae8ab0cd5897422ebe7db0501", - "version" : "12.7.3" + "revision" : "0ead44350d2737db384908569c012fe67c421e4d", + "version" : "12.8.0" } }, { diff --git a/IceCubesApp/App/AppRegistry.swift b/IceCubesApp/App/AppRegistry.swift index 81fa6e86..b3907432 100644 --- a/IceCubesApp/App/AppRegistry.swift +++ b/IceCubesApp/App/AppRegistry.swift @@ -24,6 +24,8 @@ extension View { AccountDetailView(account: account, scrollToTopSignal: .constant(0)) case let .accountSettingsWithAccount(account, appAccount): AccountSettingsView(account: account, appAccount: appAccount) + case let .accountMediaGridView(account, initialMedia): + AccountDetailMediaGridView(account: account, initialMediaStatuses: initialMedia) case let .statusDetail(id): StatusDetailView(statusId: id) case let .statusDetailWithStatus(status): diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index 85119479..e876e622 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -79,6 +79,22 @@ public struct AccountDetailView: View { if viewModel.selectedTab == .statuses { pinnedPostsView } + if viewModel.selectedTab == .media { + HStack { + Label("Media Grid", systemImage: "square.grid.2x2") + Spacer() + Image(systemName: "chevron.right") + } + .onTapGesture { + if let account = viewModel.account { + routerPath.navigate(to: .accountMediaGridView(account: account, + initialMediaStatuses: viewModel.statusesMedias)) + } + } + #if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) + #endif + } StatusesListView(fetcher: viewModel, client: client, routerPath: routerPath) diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index 80d2270d..fae5d3c9 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -84,6 +84,9 @@ import SwiftUI private var tabTask: Task? private(set) var statuses: [Status] = [] + var statusesMedias: [MediaStatus] { + statuses.filter{ !$0.mediaAttachments.isEmpty }.flatMap{ $0.asMediaStatus} + } var boosts: [Status] = [] diff --git a/Packages/Account/Sources/Account/MediaGrid/AccountDetailMediaGridView.swift b/Packages/Account/Sources/Account/MediaGrid/AccountDetailMediaGridView.swift new file mode 100644 index 00000000..ddf0af33 --- /dev/null +++ b/Packages/Account/Sources/Account/MediaGrid/AccountDetailMediaGridView.swift @@ -0,0 +1,116 @@ +import SwiftUI +import DesignSystem +import NukeUI +import Env +import MediaUI +import Models +import Network + +@MainActor +public struct AccountDetailMediaGridView: View { + @Environment(Theme.self) private var theme + @Environment(RouterPath.self) private var routerPath + @Environment(Client.self) private var client + @Environment(QuickLook.self) private var quickLook + + let account: Account + @State var mediaStatuses: [MediaStatus] + + public init(account: Account, initialMediaStatuses: [MediaStatus]) { + self.account = account + self.mediaStatuses = initialMediaStatuses + } + + public var body: some View { + ScrollView(.vertical) { + LazyVGrid(columns: [.init(.flexible(minimum: 100), spacing: 4), + .init(.flexible(minimum: 100), spacing: 4), + .init(.flexible(minimum: 100), spacing: 4)], + spacing: 4) { + ForEach(mediaStatuses) { status in + GeometryReader { proxy in + if let url = status.attachment.url { + Group { + switch status.attachment.supportedType { + case .image: + LazyImage(url: url, transaction: Transaction(animation: .easeIn)) { state in + if let image = state.image { + image + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.width) + } else { + ProgressView() + .frame(width: proxy.size.width, height: proxy.size.width) + } + } + .processors([.resize(size: proxy.size)]) + .transition(.opacity) + case .gifv, .video: + MediaUIAttachmentVideoView(viewModel: .init(url: url)) + case .none: + EmptyView() + case .some(.audio): + EmptyView() + } + } + .onTapGesture { + routerPath.navigate(to: .statusDetailWithStatus(status: status.status)) + } + .contextMenu { + Button { + quickLook.prepareFor(selectedMediaAttachment: status.attachment, + mediaAttachments: status.status.mediaAttachments) + } label: { + Label("Open Media", systemImage: "photo") + } + MediaUIShareLink(url: url, type: status.attachment.supportedType == .image ? .image : .av) + Button { + Task { + let transferable = MediaUIImageTransferable(url: url) + UIPasteboard.general.image = UIImage(data: await transferable.fetchData()) + } + } label: { + Label("status.media.contextmenu.copy", systemImage: "doc.on.doc") + } + Button { + UIPasteboard.general.url = url + } label: { + Label("status.action.copy-link", systemImage: "link") + } + } + } + } + .clipped() + .aspectRatio(1, contentMode: .fit) + } + + VStack { + Spacer() + NextPageView { + try await fetchNextPage() + } + Spacer() + } + } + } + .navigationTitle(account.displayName ?? "") + #if !os(visionOS) + .scrollContentBackground(.hidden) + .background(theme.primaryBackgroundColor) + #endif + } + + private func fetchNextPage() async throws { + let client = client + let newStatuses: [Status] = + try await client.get(endpoint: Accounts.statuses(id: account.id, + sinceId: mediaStatuses.last?.id, + tag: nil, + onlyMedia: true, + excludeReplies: true, + excludeReblogs: true, + pinned: nil)) + mediaStatuses.append(contentsOf: newStatuses.flatMap{ $0.asMediaStatus }) + } +} diff --git a/Packages/DesignSystem/Package.swift b/Packages/DesignSystem/Package.swift index ae4574cd..bb212390 100644 --- a/Packages/DesignSystem/Package.swift +++ b/Packages/DesignSystem/Package.swift @@ -19,7 +19,7 @@ let package = Package( dependencies: [ .package(name: "Models", path: "../Models"), .package(name: "Env", path: "../Env"), - .package(url: "https://github.com/kean/Nuke", exact: "12.7.3"), + .package(url: "https://github.com/kean/Nuke", exact: "12.8.0"), .package(url: "https://github.com/divadretlaw/EmojiText", exact: "4.0.0"), ], targets: [ diff --git a/Packages/Env/Sources/Env/Router.swift b/Packages/Env/Sources/Env/Router.swift index 0f76def0..ac6f6ff7 100644 --- a/Packages/Env/Sources/Env/Router.swift +++ b/Packages/Env/Sources/Env/Router.swift @@ -9,6 +9,7 @@ public enum RouterDestination: Hashable { case accountDetail(id: String) case accountDetailWithAccount(account: Account) case accountSettingsWithAccount(account: Account, appAccount: AppAccount) + case accountMediaGridView(account: Account, initialMediaStatuses: [MediaStatus]) case statusDetail(id: String) case statusDetailWithStatus(status: Status) case remoteStatusDetail(url: URL) diff --git a/Packages/MediaUI/Sources/MediaUI/DisplayType.swift b/Packages/MediaUI/Sources/MediaUI/DisplayType.swift index 371e56a5..e92ac9ac 100644 --- a/Packages/MediaUI/Sources/MediaUI/DisplayType.swift +++ b/Packages/MediaUI/Sources/MediaUI/DisplayType.swift @@ -1,11 +1,11 @@ import Models import SwiftUI -enum DisplayType { +public enum DisplayType { case image case av - init(from attachmentType: MediaAttachment.SupportedType) { + public init(from attachmentType: MediaAttachment.SupportedType) { switch attachmentType { case .image: self = .image diff --git a/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentImageView.swift b/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentImageView.swift index cc5f9921..e43a4c20 100644 --- a/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentImageView.swift +++ b/Packages/MediaUI/Sources/MediaUI/MediaUIAttachmentImageView.swift @@ -2,12 +2,12 @@ import Models import NukeUI import SwiftUI -struct MediaUIAttachmentImageView: View { - let url: URL +public struct MediaUIAttachmentImageView: View { + public let url: URL @GestureState private var zoom = 1.0 - var body: some View { + public var body: some View { MediaUIZoomableContainer { LazyImage(url: url) { state in if let image = state.image { diff --git a/Packages/MediaUI/Sources/MediaUI/MediaUIShareLink.swift b/Packages/MediaUI/Sources/MediaUI/MediaUIShareLink.swift index 54e0e1f6..5ab0e198 100644 --- a/Packages/MediaUI/Sources/MediaUI/MediaUIShareLink.swift +++ b/Packages/MediaUI/Sources/MediaUI/MediaUIShareLink.swift @@ -1,10 +1,15 @@ import SwiftUI -struct MediaUIShareLink: View, @unchecked Sendable { +public struct MediaUIShareLink: View, @unchecked Sendable { let url: URL let type: DisplayType - var body: some View { + public init(url: URL, type: DisplayType) { + self.url = url + self.type = type + } + + public var body: some View { if type == .image { let transferable = MediaUIImageTransferable(url: url) ShareLink(item: transferable, preview: .init("status.media.contextmenu.share", diff --git a/Packages/MediaUI/Sources/MediaUI/MediaUITransferableImage.swift b/Packages/MediaUI/Sources/MediaUI/MediaUITransferableImage.swift index 27a1404b..78c46113 100644 --- a/Packages/MediaUI/Sources/MediaUI/MediaUITransferableImage.swift +++ b/Packages/MediaUI/Sources/MediaUI/MediaUITransferableImage.swift @@ -2,10 +2,14 @@ import CoreTransferable import SwiftUI import UIKit -struct MediaUIImageTransferable: Codable, Transferable { - let url: URL +public struct MediaUIImageTransferable: Codable, Transferable { + public let url: URL - func fetchData() async -> Data { + public init(url: URL) { + self.url = url + } + + public func fetchData() async -> Data { do { return try await URLSession.shared.data(from: url).0 } catch { @@ -13,7 +17,7 @@ struct MediaUIImageTransferable: Codable, Transferable { } } - static var transferRepresentation: some TransferRepresentation { + public static var transferRepresentation: some TransferRepresentation { DataRepresentation(exportedContentType: .jpeg) { transferable in await transferable.fetchData() } diff --git a/Packages/Models/Sources/Models/MediaStatus.swift b/Packages/Models/Sources/Models/MediaStatus.swift new file mode 100644 index 00000000..69088738 --- /dev/null +++ b/Packages/Models/Sources/Models/MediaStatus.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct MediaStatus: Sendable, Identifiable, Hashable { + public var id: String { + attachment.id + } + + public let status: Status + public let attachment: MediaAttachment + + public init(status: Status, attachment: MediaAttachment) { + self.status = status + self.attachment = attachment + } +} diff --git a/Packages/Models/Sources/Models/Status.swift b/Packages/Models/Sources/Models/Status.swift index 6edd2f35..449809b5 100644 --- a/Packages/Models/Sources/Models/Status.swift +++ b/Packages/Models/Sources/Models/Status.swift @@ -77,6 +77,10 @@ public final class Status: AnyStatus, Codable, Identifiable, Equatable, Hashable public var isHidden: Bool { filtered?.first?.filter.filterAction == .hide } + + public var asMediaStatus: [MediaStatus] { + mediaAttachments.map{ .init(status: self, attachment: $0)} + } public init(id: String, content: HTMLString, account: Account, createdAt: ServerDate, editedAt: ServerDate?, reblog: ReblogStatus?, mediaAttachments: [MediaAttachment], mentions: [Mention], repliesCount: Int, reblogsCount: Int, favouritesCount: Int, card: Card?, favourited: Bool?, reblogged: Bool?, pinned: Bool?, bookmarked: Bool?, emojis: [Emoji], url: String?, application: Application?, inReplyToId: String?, inReplyToAccountId: String?, visibility: Visibility, poll: Poll?, spoilerText: HTMLString, filtered: [Filtered]?, sensitive: Bool, language: String?) { self.id = id