git-subtree-dir: osx git-subtree-mainline:034026e88agit-subtree-split:46755a07ef
242 lines
6.9 KiB
Swift
242 lines
6.9 KiB
Swift
//
|
|
// TranscriptDisplayItemViews.swift
|
|
// kordophone2
|
|
//
|
|
// Created by James Magahern on 8/24/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct BubbleView<Content: View>: 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
|
|
}
|
|
}
|