// // 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 private let imageMaxWidth: CGFloat = 340.0 @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(sender: sender, date: date) { if let img { Image(nsImage: img) .resizable() .scaledToFit() .frame( maxWidth: CGFloat.minimum(imageMaxWidth, containerWidth ?? imageMaxWidth) ) } 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 } try handle.close() } catch { print("Attachment file handle acquisition error: \(error)") } } } } struct PlaceholderImageItemView: View { let sender: Display.Sender let date: Date let size: CGSize init(sender: Display.Sender, date: Date, size: CGSize?) { self.sender = sender self.date = date self.size = size ?? CGSize(width: 250.0, height: 100.0) } var body: some View { BubbleView(sender: sender, date: date) { 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() } } } 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 }