diff --git a/Packages/Models/Sources/Models/MediaAttachement.swift b/Packages/Models/Sources/Models/MediaAttachement.swift index 18b02fb3..1c52776d 100644 --- a/Packages/Models/Sources/Models/MediaAttachement.swift +++ b/Packages/Models/Sources/Models/MediaAttachement.swift @@ -50,7 +50,7 @@ public struct MediaAttachment: Codable, Identifiable, Hashable, Equatable { type: "image", url: url, previewUrl: url, - description: nil, + description: "demo alt text here", meta: nil) } } diff --git a/Packages/Status/Sources/Status/Row/Subviews/StatusRowMediaPreviewView.swift b/Packages/Status/Sources/Status/Row/Subviews/StatusRowMediaPreviewView.swift index b594f3ea..713e5cf0 100644 --- a/Packages/Status/Sources/Status/Row/Subviews/StatusRowMediaPreviewView.swift +++ b/Packages/Status/Sources/Status/Row/Subviews/StatusRowMediaPreviewView.swift @@ -9,11 +9,8 @@ import SwiftUI @MainActor public struct StatusRowMediaPreviewView: View { @Environment(\.openWindow) private var openWindow - @Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool @Environment(\.extraLeadingInset) private var extraLeadingInset: CGFloat - @Environment(\.isInCaptureMode) private var isInCaptureMode: Bool @Environment(\.isCompact) private var isCompact: Bool - @Environment(SceneDelegate.self) private var sceneDelegate @Environment(UserPreferences.self) private var preferences @Environment(QuickLook.self) private var quickLook @@ -23,9 +20,6 @@ public struct StatusRowMediaPreviewView: View { public let sensitive: Bool @State private var isQuickLookLoading: Bool = false - @State private var altTextDisplayed: String? - @State private var isAltAlertDisplayed: Bool = false - @State private var isHidingMedia: Bool = false var availableWidth: CGFloat { if UIDevice.current.userInterfaceIdiom == .phone && @@ -66,39 +60,20 @@ public struct StatusRowMediaPreviewView: View { return attachments.count > 2 ? 150 : 200 } - private func size(for media: MediaAttachment) -> CGSize? { - if let width = media.meta?.original?.width, - let height = media.meta?.original?.height - { - return .init(width: CGFloat(width), height: CGFloat(height)) - } - return nil - } - - private func imageSize(from: CGSize, newWidth: CGFloat) -> CGSize { - if isCompact || theme.statusDisplayStyle == .compact || isSecondaryColumn { - return .init(width: imageMaxHeight, height: imageMaxHeight) - } - let ratio = newWidth / from.width - let newHeight = from.height * ratio - return .init(width: newWidth, height: newHeight) - } - public var body: some View { Group { - if attachments.count == 1, let attachment = attachments.first { - makeFeaturedImagePreview(attachment: attachment) - .onTapGesture { - if ProcessInfo.processInfo.isMacCatalystApp { - openWindow(value: WindowDestination.mediaViewer(attachments: attachments, - selectedAttachment: attachment)) - } else { - quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments) - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(Self.accessibilityLabel(for: attachment)) - .accessibilityAddTraits([.isButton, .isImage]) + if attachments.count == 1 { + FeaturedImagePreView( + attachment: attachments[0], + imageMaxHeight: imageMaxHeight, + sensitive: sensitive, + appLayoutWidth: appLayoutWidth, + availableWidth: availableWidth + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Self.accessibilityLabel(for: attachments[0])) + .accessibilityAddTraits([.isButton, .isImage]) + .onTapGesture { tabAction(for: 0) } } else { if isCompact || theme.statusDisplayStyle == .compact { HStack { @@ -121,212 +96,36 @@ public struct StatusRowMediaPreviewView: View { } } } - .overlay { - if isHidingMedia { - sensitiveMediaOverlay - .transition(.opacity) - } - } - .alert("status.editor.media.image-description", - isPresented: $isAltAlertDisplayed) - { - Button("alert.button.ok", action: {}) - } message: { - Text(altTextDisplayed ?? "") - } - .onAppear { - if sensitive, preferences.autoExpandMedia == .hideSensitive { - isHidingMedia = true - } else if preferences.autoExpandMedia == .hideAll { - isHidingMedia = true - } else { - isHidingMedia = false - } - } } @ViewBuilder private func makeAttachmentView(for index: Int) -> some View { - if attachments.count > index { - makePreview(attachment: attachments[index]) + if + attachments.count > index, + let data = DisplayData(from: attachments[index]) + { + MediaPreview( + sensitive: sensitive, + imageMaxHeight: imageMaxHeight, + displayData: data + ) + .onTapGesture { tabAction(for: index) } } } - @ViewBuilder - private func makeFeaturedImagePreview(attachment: MediaAttachment) -> some View { - ZStack(alignment: .bottomLeading) { - let size: CGSize = size(for: attachment) ?? .init(width: imageMaxHeight, height: imageMaxHeight) - let newSize = imageSize(from: size, newWidth: availableWidth - appLayoutWidth) - switch attachment.supportedType { - case .image: - LazyImage(url: attachment.url) { state in - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: newSize.width, height: newSize.height) - .clipped() - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(.gray.opacity(0.35), lineWidth: 1) - ) - } else { - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray) - .frame(width: newSize.width, height: newSize.height) - } - } - .processors([.resize(size: newSize)]) - .frame(width: newSize.width, height: newSize.height) - - case .gifv, .video, .audio: - if let url = attachment.url { - MediaUIAttachmentVideoView(viewModel: .init(url: url)) - .frame(width: newSize.width, height: newSize.height) - } - case .none: - EmptyView() - } - if !isInCaptureMode, sensitive { - cornerSensitiveButton - } - if !isInCaptureMode, let alt = attachment.description, !alt.isEmpty, !isCompact, preferences.showAltTextForMedia { - Group { - Button { - altTextDisplayed = alt - isAltAlertDisplayed = true - } label: { - Text("status.image.alt-text.abbreviation") - .font(theme.statusDisplayStyle == .compact ? .footnote : .body) - } - .buttonStyle(.borderless) - .padding(4) - .background(.thinMaterial) - .cornerRadius(4) - } - .padding(theme.statusDisplayStyle == .compact ? 0 : 10) - } - } - } - - @ViewBuilder - private func makePreview(attachment: MediaAttachment) -> some View { - if let type = attachment.supportedType, !isInCaptureMode { - Group { - GeometryReader { proxy in - switch type { - case .image: - ZStack(alignment: .bottomTrailing) { - LazyResizableImage(url: attachment.previewUrl ?? attachment.url) { state, proxy in - let width = isCompact ? imageMaxHeight : proxy.frame(in: .local).width - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: width) - .frame(maxHeight: imageMaxHeight) - .clipped() - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(.gray.opacity(0.35), lineWidth: 1) - ) - } else if state.isLoading { - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray) - .frame(maxHeight: imageMaxHeight) - .frame(maxWidth: width) - } - } - if sensitive, !isInCaptureMode { - cornerSensitiveButton - } - if !isInCaptureMode, - let alt = attachment.description, - !alt.isEmpty, - !isCompact, - preferences.showAltTextForMedia - { - Button { - altTextDisplayed = alt - isAltAlertDisplayed = true - } label: { - Text("status.image.alt-text.abbreviation") - .font(.scaledFootnote) - } - .buttonStyle(.borderless) - .padding(4) - .background(.thinMaterial) - .cornerRadius(4) - } - } - case .gifv, .video, .audio: - if let url = attachment.url { - MediaUIAttachmentVideoView(viewModel: .init(url: url)) - .frame(width: isCompact ? imageMaxHeight : proxy.frame(in: .local).width) - .frame(height: imageMaxHeight) - .accessibilityAddTraits(.startsMediaSession) - } - } - } - .frame(maxWidth: isCompact ? imageMaxHeight : nil) - .frame(height: imageMaxHeight) - } - // #965: do not create overlapping tappable areas, when multiple images are shown - .contentShape(Rectangle()) - .onTapGesture { - if ProcessInfo.processInfo.isMacCatalystApp { - openWindow(value: WindowDestination.mediaViewer(attachments: attachments, - selectedAttachment: attachment)) - } else { - quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments) - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(Self.accessibilityLabel(for: attachment)) - .accessibilityAddTraits(attachment.supportedType == .image ? [.isImage, .isButton] : .isButton) - } - } - - private var sensitiveMediaOverlay: some View { - ZStack { - Rectangle() - .foregroundColor(.clear) - .background(.ultraThinMaterial) - if !isCompact { - Button { - withAnimation { - isHidingMedia = false - } - } label: { - Group { - if sensitive { - Label("status.media.sensitive.show", systemImage: "eye") - } else { - Label("status.media.content.show", systemImage: "eye") - } - } - .foregroundColor(theme.labelColor) - } - .buttonStyle(.borderedProminent) - } - } - } - - private var cornerSensitiveButton: some View { - HStack { - Button { - withAnimation { - isHidingMedia = true - } - } label: { - Image(systemName: "eye.slash") - .frame(minHeight: 21) // Match the alt button in case it is also present - } - .padding(10) - .buttonStyle(.borderedProminent) - Spacer() + private func tabAction(for index: Int) { + if ProcessInfo.processInfo.isMacCatalystApp { + openWindow( + value: WindowDestination.mediaViewer( + attachments: attachments, + selectedAttachment: attachments[index] + ) + ) + } else { + quickLook.prepareFor( + selectedMediaAttachment: attachments[index], + mediaAttachments: attachments + ) } } @@ -340,3 +139,352 @@ public struct StatusRowMediaPreviewView: View { } } } + +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 { proxy in + switch displayData.type { + case .image: + LazyResizableImage(url: displayData.previewUrl) { state, proxy in + let width = isCompact ? imageMaxHeight : proxy.frame(in: .local).width + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: width, maxHeight: imageMaxHeight) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.gray.opacity(0.35), lineWidth: 1) + ) + } else if state.isLoading { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray) + .frame(maxWidth: width, maxHeight: imageMaxHeight) + } + } + .overlay { + BlurOverLay(sensitive: sensitive, font: .scaledFootnote) + } + .overlay { + AltTextButton(text: displayData.description, font: .scaledFootnote) + } + case .av: + MediaUIAttachmentVideoView(viewModel: .init(url: displayData.url)) + .frame(width: isCompact ? imageMaxHeight : proxy.frame(in: .local).width) + .frame(height: imageMaxHeight) + .accessibilityAddTraits(.startsMediaSession) + } + } + .frame(maxWidth: isCompact ? imageMaxHeight : nil) + .frame(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 + + @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, newWidth: availableWidth - appLayoutWidth) + 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 } + + return .init(width: CGFloat(width), height: CGFloat(height)) + } + + private func imageSize(from: CGSize, newWidth: CGFloat) -> CGSize { + if isCompact || theme.statusDisplayStyle == .compact || isSecondaryColumn { + return .init(width: imageMaxHeight, height: imageMaxHeight) + } + let ratio = newWidth / from.width + let newHeight = from.height * ratio + return .init(width: newWidth, height: newHeight) + } +} + +@MainActor +struct BlurOverLay: View { + let sensitive: Bool + let font: Font? + + @State private var isFrameExpanded = true + @State private var isTextExpanded = 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(duration: 0.2)) { + isTextExpanded.toggle() + } completion: { + withAnimation(.spring(duration: 0.3)) { + isFrameExpanded.toggle() + } + } + } label: { + if isTextExpanded { + ViewThatFits(in: .horizontal) { + HStack { + Image(systemName: "eye") + Text(sensitive ? "status.media.sensitive.show" : "status.media.content.show" ) + } + HStack { + Image(systemName: "eye") + Text("Show") + } + Image(systemName: "eye") + } + .lineLimit(1) + .foregroundColor(theme.labelColor) + .matchedGeometryEffect(id: "text", in: buttonSpace) + } else { + Image(systemName: "eye.slash") + .matchedGeometryEffect(id: "text", 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, + !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 + + 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 + self.previewUrl = attachment.previewUrl ?? attachment.url + description = attachment.description + self.type = DisplayType(from: type) + accessibilityText = Self.getAccessibilityString(from: attachment) + } + + 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() + } + + static private let url = URL(string: "https://www.upwork.com/catalog-images/c5dffd9b5094556adb26e0a193a1c494")! + static private let attachment = MediaAttachment.imageWith(url: url) + static private let local = Locale(identifier: "en") +}