// // TranscriptDisplayItemViews.swift // kordophone2 // // Created by James Magahern on 8/24/25. // import SwiftUI struct BubbleView: View { let isFromMe: Bool let content: () -> Content init(isFromMe: Bool, @ViewBuilder content: @escaping () -> Content) { self.isFromMe = isFromMe self.content = content } var body: some View { VStack { HStack(alignment: .bottom) { if isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } content() .mask { UnevenRoundedRectangle(cornerRadii: RectangleCornerRadii( topLeading: isFromMe ? .dominantCornerRadius : .minorCornerRadius, bottomLeading: .dominantCornerRadius, bottomTrailing: .dominantCornerRadius, topTrailing: isFromMe ? .minorCornerRadius : .dominantCornerRadius, )) } if !isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } } } } } struct TextBubbleItemView: View { let text: String let isFromMe: Bool var body: some View { let bubbleColor: Color = isFromMe ? .blue : Color(NSColor(name: "grayish", dynamicProvider: { appearance in appearance.name == .darkAqua ? .darkGray : NSColor(white: 0.78, alpha: 1.0) })) let textColor: Color = isFromMe ? .white : .primary BubbleView(isFromMe: isFromMe) { HStack { Text(text) .foregroundStyle(textColor) .multilineTextAlignment(.leading) } .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 16.0) .padding(.vertical, 10.0) .background(bubbleColor) } } } struct ImageItemView: View { let isFromMe: Bool let attachment: Display.ImageAttachment @State private var img: NSImage? @Environment(\.xpcClient) var xpcClient @State private var containerWidth: CGFloat? = nil var aspectRatio: CGFloat { if let size = attachment.size, size.height > 0 { return size.width / size.height } else { return 1.0 } } var preferredSize: CGSize { return CGSize( width: preferredWidth, height: preferredWidth / aspectRatio ) } var preferredWidth: CGFloat { if let containerWidth, let attachmentWidth = attachment.size?.width { return CGFloat.minimum(containerWidth, attachmentWidth) } else if let containerWidth { return containerWidth } else { return 200.0 // fallback } } var body: some View { BubbleView(isFromMe: isFromMe) { if let img { Image(nsImage: img) .resizable() .scaledToFit() .frame( maxWidth: containerWidth ) } else { Rectangle() .fill(.gray) } } .onGeometryChange(for: CGFloat.self, of: { $0.size.width }, action: { containerWidth = $0 }) .task { do { let handle = try await xpcClient.openAttachmentFileHandle( attachmentId: attachment.id, preview: true ) try? handle.seek(toOffset: 0) if let data = try? handle.readToEnd(), let ns = NSImage(data: data) { img = ns } } catch { print("Attachment file handle acquisition error: \(error)") } } } } struct PlaceholderImageItemView: View { let isFromMe: Bool let size: CGSize init(isFromMe: Bool, size: CGSize?) { self.isFromMe = isFromMe self.size = size ?? CGSize(width: 250.0, height: 100.0) } var body: some View { BubbleView(isFromMe: isFromMe) { Color.gray .frame(width: size.width, height: size.height) } } } struct DateItemView: View { let date: Date private let formatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "EEEE HH:mm" return f }() var body: some View { VStack { Spacer(minLength: 34.0) Text(formatter.string(from: date)) .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 4) Spacer() } } } fileprivate extension CGFloat { static let dominantCornerRadius = 16.0 static let minorCornerRadius = 4.0 static let minimumBubbleHorizontalPadding = 80.0 }