Private
Public Access
1
0

Add 'osx/' from commit '46755a07ef2e7aa9852d74c30e2c12f9fe8f2278'

git-subtree-dir: osx
git-subtree-mainline: 034026e88a
git-subtree-split: 46755a07ef
This commit is contained in:
2025-09-06 19:38:26 -07:00
22 changed files with 2598 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
//
// 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
}
}

View File

@@ -0,0 +1,82 @@
//
// TranscriptDisplayItems.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import Foundation
import SwiftUI
extension TranscriptView.ViewModel
{
internal func rebuildDisplayItems(animated: Bool = false) {
var displayItems: [DisplayItem] = []
var lastDate: Date = .distantPast
var lastSender: Display.Sender? = nil
let client = XPCClient()
let isGroupChat = displayedConversation?.isGroupChat ?? false
let dateAnnotationTimeInterval: TimeInterval = 60 * 30
for message in messages {
if message.sender != lastSender {
displayItems.append(.spacer(15.0, message.id))
}
let isPastDateThreshold = message.date.timeIntervalSince(lastDate) > dateAnnotationTimeInterval
if isPastDateThreshold {
displayItems.append(.date(message.date))
}
if isGroupChat && !message.sender.isMe && (isPastDateThreshold || message.sender != lastSender) {
displayItems.append(.senderAttribition(message))
}
for attachment in message.attachments {
displayItems.append(.attachment(attachment))
if !attachment.isPreviewDownloaded {
Task.detached {
try await client.downloadAttachment(attachmentId: attachment.id, preview: true)
}
}
}
if !message.text.isEmpty {
displayItems.append(.message(message))
}
lastSender = message.sender
lastDate = message.date
}
let animation: Animation? = animated ? .default : nil
withAnimation(animation) {
self.displayItems = displayItems
}
}
}
enum DisplayItem: Identifiable
{
case message(Display.Message)
case attachment(Display.ImageAttachment)
case senderAttribition(Display.Message)
case spacer(CGFloat, Display.Message.ID)
case date(Date)
var id: String {
switch self {
case .message(let message):
message.id
case .attachment(let attachment):
attachment.id
case .senderAttribition(let message):
"\(message.sender.displayName) @ \(message.id)"
case .date(let date):
date.description
case .spacer(let space, let id):
"\(space)=\(id)"
}
}
}

View File

@@ -0,0 +1,194 @@
//
// TranscriptView.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import SwiftUI
struct TranscriptView: View
{
@Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient
var body: some View {
ScrollView {
LazyVStack(spacing: 6.0) {
ForEach($model.displayItems.reversed()) { item in
displayItemView(item.wrappedValue)
.id(item.id)
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.transition(
.push(from: .top)
.combined(with: .opacity)
)
}
}
.padding()
}
.scaleEffect(CGSize(width: 1.0, height: -1.0))
.id(model.displayedConversation?.id)
.task { await watchForMessageListChanges() }
}
private func watchForMessageListChanges() async {
for await event in xpcClient.eventStream() {
switch event {
case .attachmentDownloaded(let attachmentId):
model.attachmentDownloaded(id: attachmentId)
case .messagesUpdated(let conversationId):
if let displayedConversation = model.displayedConversation,
conversationId == displayedConversation.id
{
model.setNeedsReload(animated: true)
}
case .updateStreamReconnected:
await model.triggerSync()
default:
break
}
}
}
@ViewBuilder
private func displayItemView(_ item: DisplayItem) -> some View {
switch item {
case .message(let message):
TextBubbleItemView(text: message.text, sender: message.sender, date: message.date)
case .date(let date):
DateItemView(date: date)
case .senderAttribition(let message):
SenderAttributionView(sender: message.sender)
case .spacer(let length, _):
Spacer(minLength: length)
case .attachment(let attachment):
if attachment.isPreviewDownloaded {
ImageItemView(
sender: attachment.sender,
date: attachment.dateSent,
attachment: attachment
)
} else {
PlaceholderImageItemView(
sender: attachment.sender,
date: attachment.dateSent,
size: attachment.size
)
}
}
}
// MARK: - Types
@Observable
class ViewModel
{
var displayItems: [DisplayItem] = []
var displayedConversation: Display.Conversation? = nil
internal var needsReload: NeedsReload = .no
internal var messages: [Display.Message]
internal let client = XPCClient()
init(messages: [Display.Message] = []) {
self.messages = messages
observeDisplayedConversation()
rebuildDisplayItems()
}
func setNeedsReload(animated: Bool) {
guard case .no = needsReload else {
return
}
needsReload = .yes(animated)
Task { @MainActor [weak self] in
guard let self else { return }
await reloadMessages()
}
}
func attachmentDownloaded(id: String) {
// TODO: should be smarter here
setNeedsReload(animated: false)
}
private func observeDisplayedConversation() {
withObservationTracking {
_ = displayedConversation
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
await markAsRead()
await triggerSync()
setNeedsReload(animated: false)
observeDisplayedConversation()
}
}
}
func markAsRead() async {
guard let displayedConversation else { return }
do {
try await client.markConversationAsRead(conversationId: displayedConversation.id)
} catch {
print("Error triggering sync: \(error)")
}
}
func triggerSync() async {
guard let displayedConversation else { return }
do {
try await client.syncConversation(conversationId: displayedConversation.id)
} catch {
print("Error triggering sync: \(error)")
}
}
private func reloadMessages() async {
guard case .yes(let animated) = needsReload else { return }
needsReload = .no
guard let displayedConversation else { return }
do {
let clientMessages = try await client.getMessages(conversationId: displayedConversation.id)
.map { Display.Message(from: $0) }
let newIds = Set(clientMessages.map(\.id))
.subtracting(self.messages.map(\.id))
// Only animate for incoming messages.
let shouldAnimate = (newIds.count == 1)
self.messages = clientMessages
self.rebuildDisplayItems(animated: animated && shouldAnimate)
} catch {
print("Message fetch error: \(error)")
}
}
// MARK: - Types
enum NeedsReload
{
case no
case yes(Bool) // animated
}
}
}
#Preview {
@Previewable @State var model = TranscriptView.ViewModel(messages: [
.init(sender: .me, text: "Hello, how are you?"),
.init(sender: .counterpart("Bob"), text: "I am doing fine!")
])
TranscriptView(model: $model)
}