mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-06-02 08:50:40 +00:00
505 lines
15 KiB
Swift
505 lines
15 KiB
Swift
import DesignSystem
|
|
import Env
|
|
import MediaUI
|
|
import Models
|
|
import Nuke
|
|
import NukeUI
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
public struct StatusRowMediaPreviewView: View {
|
|
@Environment(\.isPresented) private var isPresented
|
|
@Environment(\.openWindow) private var openWindow
|
|
@Environment(\.extraLeadingInset) private var extraLeadingInset: CGFloat
|
|
@Environment(\.isCompact) private var isCompact: Bool
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@Environment(SceneDelegate.self) private var sceneDelegate
|
|
@Environment(UserPreferences.self) private var preferences
|
|
@Environment(QuickLook.self) private var quickLook
|
|
@Environment(Theme.self) private var theme
|
|
|
|
public let attachments: [MediaAttachment]
|
|
public let sensitive: Bool
|
|
|
|
@State private var isQuickLookLoading: Bool = false
|
|
|
|
var availableWidth: CGFloat {
|
|
#if os(visionOS)
|
|
return sceneDelegate.windowWidth * 0.96
|
|
#else
|
|
if UIDevice.current.userInterfaceIdiom == .phone &&
|
|
(UIDevice.current.orientation == .landscapeLeft || UIDevice.current.orientation == .landscapeRight) || theme.statusDisplayStyle == .medium
|
|
{
|
|
return sceneDelegate.windowWidth * 0.80
|
|
}
|
|
if isPresented && UIDevice.current.userInterfaceIdiom == .pad {
|
|
return sceneDelegate.windowWidth * 0.50
|
|
} else {
|
|
return sceneDelegate.windowWidth
|
|
}
|
|
#endif
|
|
}
|
|
|
|
var appLayoutWidth: CGFloat {
|
|
let avatarColumnWidth = theme.avatarPosition == .leading ? AvatarView.FrameConfig.status.width + .statusColumnsSpacing : 0
|
|
var sidebarWidth: CGFloat = 0
|
|
var secondaryColumnWidth: CGFloat = 0
|
|
let layoutPading: CGFloat = .layoutPadding * 2
|
|
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass != .compact {
|
|
sidebarWidth = .sidebarWidth
|
|
if preferences.showiPadSecondaryColumn {
|
|
secondaryColumnWidth = .secondaryColumnWidth
|
|
}
|
|
}
|
|
return layoutPading + avatarColumnWidth + sidebarWidth + extraLeadingInset + secondaryColumnWidth
|
|
}
|
|
|
|
private var imageMaxHeight: CGFloat {
|
|
if isCompact {
|
|
return 50
|
|
}
|
|
if theme.statusDisplayStyle == .compact {
|
|
if attachments.count == 1 {
|
|
return 200
|
|
}
|
|
return 100
|
|
}
|
|
return 300
|
|
}
|
|
|
|
public var body: some View {
|
|
Group {
|
|
if attachments.count == 1 {
|
|
FeaturedImagePreView(
|
|
attachment: attachments[0],
|
|
imageMaxHeight: imageMaxHeight,
|
|
sensitive: sensitive,
|
|
appLayoutWidth: appLayoutWidth,
|
|
availableWidth: availableWidth,
|
|
availableHeight: sceneDelegate.windowHeight
|
|
)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(Self.accessibilityLabel(for: attachments[0]))
|
|
.accessibilityAddTraits([.isButton, .isImage])
|
|
.onTapGesture { tabAction(for: 0) }
|
|
} else {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack {
|
|
makeAttachmentView(for: 0)
|
|
makeAttachmentView(for: 1)
|
|
makeAttachmentView(for: 2)
|
|
makeAttachmentView(for: 3)
|
|
}
|
|
}
|
|
.scrollClipDisabled()
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func makeAttachmentView(for index: Int) -> some View {
|
|
if
|
|
attachments.count > index,
|
|
let data = DisplayData(from: attachments[index])
|
|
{
|
|
MediaPreview(
|
|
sensitive: sensitive,
|
|
imageMaxHeight: imageMaxHeight,
|
|
displayData: data
|
|
)
|
|
.onTapGesture { tabAction(for: index) }
|
|
}
|
|
}
|
|
|
|
private func tabAction(for index: Int) {
|
|
#if targetEnvironment(macCatalyst) || os(visionOS)
|
|
openWindow(
|
|
value: WindowDestinationMedia.mediaViewer(
|
|
attachments: attachments,
|
|
selectedAttachment: attachments[index]
|
|
)
|
|
)
|
|
#else
|
|
quickLook.prepareFor(
|
|
selectedMediaAttachment: attachments[index],
|
|
mediaAttachments: attachments
|
|
)
|
|
#endif
|
|
}
|
|
|
|
private static func accessibilityLabel(for attachment: MediaAttachment) -> Text {
|
|
if let altText = attachment.description {
|
|
Text("accessibility.image.alt-text-\(altText)")
|
|
} else if let typeDescription = attachment.localizedTypeDescription {
|
|
Text(typeDescription)
|
|
} else {
|
|
Text("accessibility.tabs.profile.picker.media")
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MediaPreview: View {
|
|
let sensitive: Bool
|
|
let imageMaxHeight: CGFloat
|
|
let displayData: DisplayData
|
|
|
|
@Environment(UserPreferences.self) private var preferences
|
|
@Environment(\.isCompact) private var isCompact: Bool
|
|
|
|
var body: some View {
|
|
GeometryReader { _ in
|
|
switch displayData.type {
|
|
case .image:
|
|
LazyResizableImage(url: displayData.previewUrl) { state, _ in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: displayData.isLandscape ? imageMaxHeight * 1.2 : imageMaxHeight / 1.5,
|
|
height: imageMaxHeight)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.stroke(.gray.opacity(0.35), lineWidth: 1)
|
|
)
|
|
} else if state.isLoading {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.gray)
|
|
}
|
|
}
|
|
.overlay {
|
|
BlurOverLay(sensitive: sensitive, font: .scaledFootnote)
|
|
}
|
|
.overlay {
|
|
AltTextButton(text: displayData.description, font: .scaledFootnote)
|
|
}
|
|
case .av:
|
|
MediaUIAttachmentVideoView(viewModel: .init(url: displayData.url))
|
|
.accessibilityAddTraits(.startsMediaSession)
|
|
}
|
|
}
|
|
.frame(width: displayData.isLandscape ? imageMaxHeight * 1.2 : imageMaxHeight / 1.5,
|
|
height: imageMaxHeight)
|
|
.clipped()
|
|
.cornerRadius(4)
|
|
// #965: do not create overlapping tappable areas, when multiple images are shown
|
|
.contentShape(Rectangle())
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(Text(displayData.accessibilityText))
|
|
.accessibilityAddTraits(displayData.type == .image ? [.isImage, .isButton] : .isButton)
|
|
}
|
|
}
|
|
|
|
private struct FeaturedImagePreView: View {
|
|
let attachment: MediaAttachment
|
|
let imageMaxHeight: CGFloat
|
|
let sensitive: Bool
|
|
let appLayoutWidth: CGFloat
|
|
let availableWidth: CGFloat
|
|
let availableHeight: CGFloat
|
|
|
|
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
|
|
@Environment(Theme.self) private var theme
|
|
@Environment(\.isCompact) private var isCompact: Bool
|
|
|
|
var body: some View {
|
|
let size: CGSize = size(for: attachment) ?? .init(width: imageMaxHeight, height: imageMaxHeight)
|
|
let newSize = imageSize(from: size)
|
|
Group {
|
|
switch attachment.supportedType {
|
|
case .image:
|
|
LazyImage(url: attachment.url) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.stroke(.gray.opacity(0.35), lineWidth: 1)
|
|
)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 4).fill(Color.gray)
|
|
}
|
|
}
|
|
.processors([.resize(size: newSize)])
|
|
case .gifv, .video, .audio:
|
|
if let url = attachment.url {
|
|
MediaUIAttachmentVideoView(viewModel: .init(url: url))
|
|
}
|
|
case .none:
|
|
EmptyView()
|
|
}
|
|
}
|
|
.frame(width: newSize.width, height: newSize.height)
|
|
.overlay {
|
|
BlurOverLay(sensitive: sensitive, font: .scaledFootnote)
|
|
}
|
|
.overlay {
|
|
AltTextButton(
|
|
text: attachment.description,
|
|
font: theme.statusDisplayStyle == .compact ? .footnote : .body
|
|
)
|
|
}
|
|
.clipped()
|
|
.cornerRadius(4)
|
|
}
|
|
|
|
private func size(for media: MediaAttachment) -> CGSize? {
|
|
guard let width = media.meta?.original?.width,
|
|
let height = media.meta?.original?.height
|
|
else { return nil }
|
|
|
|
guard width != 1 && height != 1 else {
|
|
return .init(width: 800, height: 600)
|
|
}
|
|
|
|
return .init(width: CGFloat(width), height: CGFloat(height))
|
|
}
|
|
|
|
private func imageSize(from: CGSize) -> CGSize {
|
|
if isCompact || theme.statusDisplayStyle == .compact || isSecondaryColumn {
|
|
return .init(width: imageMaxHeight, height: imageMaxHeight)
|
|
}
|
|
|
|
let boxWidth = availableWidth - appLayoutWidth
|
|
let boxHeight = availableHeight * 0.8 // use only 80% of window height to leave room for text
|
|
|
|
if from.width <= boxWidth, from.height <= boxHeight {
|
|
// intrinsic size of image fits just fine
|
|
return from
|
|
}
|
|
|
|
// shrink image proportionally to fit inside the box
|
|
let xRatio = boxWidth / from.width
|
|
let yRatio = boxHeight / from.height
|
|
if xRatio < yRatio {
|
|
return .init(width: boxWidth, height: from.height * xRatio)
|
|
} else {
|
|
return .init(width: from.width * yRatio, height: boxHeight)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct BlurOverLay: View {
|
|
let sensitive: Bool
|
|
let font: Font?
|
|
|
|
@State private var isFrameExpanded = true
|
|
|
|
@Environment(Theme.self) private var theme
|
|
@Environment(\.isInCaptureMode) private var isInCaptureMode: Bool
|
|
@Environment(UserPreferences.self) private var preferences
|
|
@Environment(\.isCompact) private var isCompact: Bool
|
|
|
|
@Namespace var buttonSpace
|
|
|
|
var body: some View {
|
|
if hasOverlay {
|
|
ZStack {
|
|
Rectangle()
|
|
.foregroundColor(.clear)
|
|
.background(.ultraThinMaterial)
|
|
.frame(
|
|
width: isFrameExpanded ? nil : 0,
|
|
height: isFrameExpanded ? nil : 0
|
|
)
|
|
if !isCompact {
|
|
Button {
|
|
withAnimation(.spring) {
|
|
isFrameExpanded.toggle()
|
|
}
|
|
} label: {
|
|
if isFrameExpanded {
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack {
|
|
Image(systemName: "eye")
|
|
.matchedGeometryEffect(id: "eye", in: buttonSpace)
|
|
Text(sensitive ? "status.media.sensitive.show" : "status.media.content.show")
|
|
}
|
|
HStack {
|
|
Image(systemName: "eye")
|
|
.matchedGeometryEffect(id: "eye", in: buttonSpace)
|
|
Text("Show")
|
|
}
|
|
Image(systemName: "eye")
|
|
.matchedGeometryEffect(id: "eye", in: buttonSpace)
|
|
}
|
|
.lineLimit(1)
|
|
.foregroundColor(theme.labelColor)
|
|
} else {
|
|
Image(systemName: "eye.slash")
|
|
.transition(.opacity)
|
|
.matchedGeometryEffect(id: "eye", in: buttonSpace)
|
|
}
|
|
}
|
|
.foregroundColor(theme.labelColor)
|
|
.buttonStyle(.borderedProminent)
|
|
.padding(theme.statusDisplayStyle == .compact ? 0 : 10)
|
|
}
|
|
}
|
|
.font(font)
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
maxHeight: .infinity,
|
|
alignment: isFrameExpanded ? .center : .bottomLeading
|
|
)
|
|
} else {
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
private var hasOverlay: Bool {
|
|
switch (sensitive, preferences.autoExpandMedia) {
|
|
case (_, .hideAll), (true, .hideSensitive):
|
|
switch isInCaptureMode {
|
|
case true: false
|
|
case false: true
|
|
}
|
|
default: false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AltTextButton: View {
|
|
let text: String?
|
|
let font: Font?
|
|
|
|
@Environment(\.isInCaptureMode) private var isInCaptureMode: Bool
|
|
@Environment(\.isCompact) private var isCompact: Bool
|
|
@Environment(UserPreferences.self) private var preferences
|
|
@Environment(\.locale) private var locale
|
|
@Environment(Theme.self) private var theme
|
|
|
|
@State private var isDisplayingAlert = false
|
|
|
|
var body: some View {
|
|
if !isInCaptureMode,
|
|
let text,
|
|
!text.isEmpty,
|
|
!isCompact,
|
|
preferences.showAltTextForMedia
|
|
{
|
|
Button {
|
|
isDisplayingAlert = true
|
|
} label: {
|
|
ZStack {
|
|
// use to sync button with show/hide content button
|
|
Image(systemName: "eye.slash").opacity(0)
|
|
Text("status.image.alt-text.abbreviation")
|
|
}
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.padding(EdgeInsets(top: 5, leading: 7, bottom: 5, trailing: 7))
|
|
.background(.thinMaterial)
|
|
.cornerRadius(4)
|
|
.padding(theme.statusDisplayStyle == .compact ? 0 : 10)
|
|
.alert(
|
|
"status.editor.media.image-description",
|
|
isPresented: $isDisplayingAlert
|
|
) {
|
|
Button("alert.button.ok", action: {})
|
|
} message: {
|
|
Text(text)
|
|
}
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
maxHeight: .infinity,
|
|
alignment: .bottomTrailing
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct DisplayData: Identifiable, Hashable {
|
|
let id: String
|
|
let url: URL
|
|
let previewUrl: URL?
|
|
let description: String?
|
|
let type: DisplayType
|
|
let accessibilityText: String
|
|
let isLandscape: Bool
|
|
|
|
init?(from attachment: MediaAttachment) {
|
|
guard let url = attachment.url else { return nil }
|
|
guard let type = attachment.supportedType else { return nil }
|
|
|
|
id = attachment.id
|
|
self.url = url
|
|
previewUrl = attachment.previewUrl ?? attachment.url
|
|
description = attachment.description
|
|
self.type = DisplayType(from: type)
|
|
accessibilityText = Self.getAccessibilityString(from: attachment)
|
|
isLandscape = (attachment.meta?.original?.width ?? 0) > (attachment.meta?.original?.height ?? 0)
|
|
}
|
|
|
|
private static func getAccessibilityString(from attachment: MediaAttachment) -> String {
|
|
if let altText = attachment.description {
|
|
"accessibility.image.alt-text-\(altText)"
|
|
} else if let typeDescription = attachment.localizedTypeDescription {
|
|
typeDescription
|
|
} else {
|
|
"accessibility.tabs.profile.picker.media"
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum DisplayType {
|
|
case image
|
|
case av
|
|
|
|
init(from attachmentType: MediaAttachment.SupportedType) {
|
|
switch attachmentType {
|
|
case .image:
|
|
self = .image
|
|
case .video, .gifv, .audio:
|
|
self = .av
|
|
}
|
|
}
|
|
}
|
|
|
|
struct StatusRowMediaPreviewView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
WrapperForPreview()
|
|
}
|
|
}
|
|
|
|
struct WrapperForPreview: View {
|
|
@State private var isCompact = false
|
|
@State private var isInCaptureMode = false
|
|
|
|
var body: some View {
|
|
VStack {
|
|
ScrollView {
|
|
VStack {
|
|
ForEach(1 ..< 5) { number in
|
|
VStack {
|
|
Text("Preview for \(number) item(s)")
|
|
StatusRowMediaPreviewView(
|
|
attachments: Array(repeating: Self.attachment, count: number),
|
|
sensitive: true
|
|
)
|
|
}
|
|
.padding()
|
|
.border(.red)
|
|
}
|
|
}
|
|
}
|
|
.environment(SceneDelegate())
|
|
.environment(UserPreferences.shared)
|
|
.environment(QuickLook.shared)
|
|
.environment(Theme.shared)
|
|
.environment(\.isCompact, isCompact)
|
|
.environment(\.isInCaptureMode, isInCaptureMode)
|
|
|
|
Divider()
|
|
Toggle("Compact Mode", isOn: $isCompact.animation())
|
|
Toggle("Capture Mode", isOn: $isInCaptureMode)
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
private static let url = URL(string: "https://www.upwork.com/catalog-images/c5dffd9b5094556adb26e0a193a1c494")!
|
|
private static let attachment = MediaAttachment.imageWith(url: url)
|
|
private static let local = Locale(identifier: "en")
|
|
}
|