Add media grid on user profile

This commit is contained in:
Thomas Ricouard 2024-07-31 18:44:29 +02:00
parent 123f05538a
commit 9fa19aa132
13 changed files with 180 additions and 14 deletions

View file

@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke", "location" : "https://github.com/kean/Nuke",
"state" : { "state" : {
"revision" : "311016d972aa751ae8ab0cd5897422ebe7db0501", "revision" : "0ead44350d2737db384908569c012fe67c421e4d",
"version" : "12.7.3" "version" : "12.8.0"
} }
}, },
{ {

View file

@ -24,6 +24,8 @@ extension View {
AccountDetailView(account: account, scrollToTopSignal: .constant(0)) AccountDetailView(account: account, scrollToTopSignal: .constant(0))
case let .accountSettingsWithAccount(account, appAccount): case let .accountSettingsWithAccount(account, appAccount):
AccountSettingsView(account: account, appAccount: appAccount) AccountSettingsView(account: account, appAccount: appAccount)
case let .accountMediaGridView(account, initialMedia):
AccountDetailMediaGridView(account: account, initialMediaStatuses: initialMedia)
case let .statusDetail(id): case let .statusDetail(id):
StatusDetailView(statusId: id) StatusDetailView(statusId: id)
case let .statusDetailWithStatus(status): case let .statusDetailWithStatus(status):

View file

@ -79,6 +79,22 @@ public struct AccountDetailView: View {
if viewModel.selectedTab == .statuses { if viewModel.selectedTab == .statuses {
pinnedPostsView 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, StatusesListView(fetcher: viewModel,
client: client, client: client,
routerPath: routerPath) routerPath: routerPath)

View file

@ -84,6 +84,9 @@ import SwiftUI
private var tabTask: Task<Void, Never>? private var tabTask: Task<Void, Never>?
private(set) var statuses: [Status] = [] private(set) var statuses: [Status] = []
var statusesMedias: [MediaStatus] {
statuses.filter{ !$0.mediaAttachments.isEmpty }.flatMap{ $0.asMediaStatus}
}
var boosts: [Status] = [] var boosts: [Status] = []

View file

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

View file

@ -19,7 +19,7 @@ let package = Package(
dependencies: [ dependencies: [
.package(name: "Models", path: "../Models"), .package(name: "Models", path: "../Models"),
.package(name: "Env", path: "../Env"), .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"), .package(url: "https://github.com/divadretlaw/EmojiText", exact: "4.0.0"),
], ],
targets: [ targets: [

View file

@ -9,6 +9,7 @@ public enum RouterDestination: Hashable {
case accountDetail(id: String) case accountDetail(id: String)
case accountDetailWithAccount(account: Account) case accountDetailWithAccount(account: Account)
case accountSettingsWithAccount(account: Account, appAccount: AppAccount) case accountSettingsWithAccount(account: Account, appAccount: AppAccount)
case accountMediaGridView(account: Account, initialMediaStatuses: [MediaStatus])
case statusDetail(id: String) case statusDetail(id: String)
case statusDetailWithStatus(status: Status) case statusDetailWithStatus(status: Status)
case remoteStatusDetail(url: URL) case remoteStatusDetail(url: URL)

View file

@ -1,11 +1,11 @@
import Models import Models
import SwiftUI import SwiftUI
enum DisplayType { public enum DisplayType {
case image case image
case av case av
init(from attachmentType: MediaAttachment.SupportedType) { public init(from attachmentType: MediaAttachment.SupportedType) {
switch attachmentType { switch attachmentType {
case .image: case .image:
self = .image self = .image

View file

@ -2,12 +2,12 @@ import Models
import NukeUI import NukeUI
import SwiftUI import SwiftUI
struct MediaUIAttachmentImageView: View { public struct MediaUIAttachmentImageView: View {
let url: URL public let url: URL
@GestureState private var zoom = 1.0 @GestureState private var zoom = 1.0
var body: some View { public var body: some View {
MediaUIZoomableContainer { MediaUIZoomableContainer {
LazyImage(url: url) { state in LazyImage(url: url) { state in
if let image = state.image { if let image = state.image {

View file

@ -1,10 +1,15 @@
import SwiftUI import SwiftUI
struct MediaUIShareLink: View, @unchecked Sendable { public struct MediaUIShareLink: View, @unchecked Sendable {
let url: URL let url: URL
let type: DisplayType 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 { if type == .image {
let transferable = MediaUIImageTransferable(url: url) let transferable = MediaUIImageTransferable(url: url)
ShareLink(item: transferable, preview: .init("status.media.contextmenu.share", ShareLink(item: transferable, preview: .init("status.media.contextmenu.share",

View file

@ -2,10 +2,14 @@ import CoreTransferable
import SwiftUI import SwiftUI
import UIKit import UIKit
struct MediaUIImageTransferable: Codable, Transferable { public struct MediaUIImageTransferable: Codable, Transferable {
let url: URL public let url: URL
func fetchData() async -> Data { public init(url: URL) {
self.url = url
}
public func fetchData() async -> Data {
do { do {
return try await URLSession.shared.data(from: url).0 return try await URLSession.shared.data(from: url).0
} catch { } 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 DataRepresentation(exportedContentType: .jpeg) { transferable in
await transferable.fetchData() await transferable.fetchData()
} }

View file

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

View file

@ -78,6 +78,10 @@ public final class Status: AnyStatus, Codable, Identifiable, Equatable, Hashable
filtered?.first?.filter.filterAction == .hide 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?) { 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 self.id = id
self.content = content self.content = content