// // TranscriptDisplayItemViews.swift // kordophone2 // // Created by James Magahern on 8/24/25. // import SwiftUI struct BubbleView: View { let date: Date let sender: Display.Sender let content: () -> Content private var isFromMe: Bool { sender.isMe } private let tooltipDateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "EEEE, MMMM dd, HH:mm" return f }() init(sender: Display.Sender, date: Date, @ViewBuilder content: @escaping () -> Content) { self.sender = sender self.content = content self.date = date } var body: some View { VStack(alignment: isFromMe ? .trailing : .leading) { 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) } } } .help(tooltipDateFormatter.string(from: date)) } } struct TextBubbleItemView: View { let text: String let sender: Display.Sender let date: Date private var isFromMe: Bool { sender.isMe } 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(sender: sender, date: date) { 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 sender: Display.Sender let date: Date let attachment: Display.ImageAttachment @State private var img: NSImage? @Environment(\.xpcClient) var xpcClient @State private var containerWidth: CGFloat? = nil private var aspectRatio: CGFloat { attachment.size?.aspectRatio ?? 1.0 } private var preferredWidth: CGFloat { preferredBubbleWidth(forAttachmentSize: attachment.size, containerWidth: containerWidth, maxWidth: .imageMaxWidth) } var body: some View { BubbleView(sender: sender, date: date) { let maxWidth = CGFloat.minimum(.imageMaxWidth, containerWidth ?? .imageMaxWidth) if let img { Image(nsImage: img) .resizable() .scaledToFit() .frame(maxWidth: maxWidth) } else { Rectangle() .fill(.gray.opacity(0.4)) .frame(width: preferredWidth, height: preferredWidth / aspectRatio) .frame(maxWidth: maxWidth) } } .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 } try handle.close() } catch { print("Attachment file handle acquisition error: \(error)") } } } } struct PlaceholderImageItemView: View { let sender: Display.Sender let date: Date let size: CGSize? @State private var containerWidth: CGFloat? = nil private var aspectRatio: CGFloat { size?.aspectRatio ?? 1.0 } private var preferredWidth: CGFloat { preferredBubbleWidth(forAttachmentSize: size, containerWidth: containerWidth, maxWidth: .imageMaxWidth) } init(sender: Display.Sender, date: Date, size: CGSize?) { self.sender = sender self.date = date self.size = size } var body: some View { BubbleView(sender: sender, date: date) { ZStack { Rectangle() .fill(.gray.opacity(0.4)) .frame(width: preferredWidth, height: preferredWidth / aspectRatio) .frame(maxWidth: .imageMaxWidth) ProgressView() } } .onGeometryChange(for: CGFloat.self, of: { $0.size.width }, action: { containerWidth = $0 }) } } 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() } } } struct SenderAttributionView: View { let sender: Display.Sender var body: some View { HStack { if sender.isMe { Spacer() } Text(sender.displayName) .foregroundStyle(.secondary) .font(.caption2) if !sender.isMe { Spacer() } } .padding(.top, 10.0) } } fileprivate extension CGFloat { static let dominantCornerRadius = 16.0 static let minorCornerRadius = 4.0 static let minimumBubbleHorizontalPadding = 80.0 static let imageMaxWidth = 380.0 } fileprivate extension CGSize { var aspectRatio: CGFloat { width / height } } fileprivate func preferredBubbleWidth(forAttachmentSize attachmentSize: CGSize?, containerWidth: CGFloat?, maxWidth: CGFloat) -> CGFloat { if let containerWidth, let attachmentWidth = attachmentSize?.width { return .minimum(maxWidth, .minimum(containerWidth, attachmentWidth)) } else if let containerWidth { return containerWidth } else { return 200.0 // fallback } }