Add 'osx/' from commit '46755a07ef2e7aa9852d74c30e2c12f9fe8f2278'
git-subtree-dir: osx git-subtree-mainline:034026e88agit-subtree-split:46755a07ef
This commit is contained in:
241
osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift
Normal file
241
osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift
Normal 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
|
||||
}
|
||||
}
|
||||
82
osx/kordophone2/Transcript/TranscriptDisplayItems.swift
Normal file
82
osx/kordophone2/Transcript/TranscriptDisplayItems.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
194
osx/kordophone2/Transcript/TranscriptView.swift
Normal file
194
osx/kordophone2/Transcript/TranscriptView.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user