Private
Public Access
1
0

App icon, group member annotations, variable spacing

This commit is contained in:
2025-08-29 21:47:51 -06:00
parent 5f37f82a33
commit 7992c03fb6
7 changed files with 116 additions and 31 deletions

View File

@@ -23,6 +23,10 @@ enum Display
else { return participants.joined(separator: ", ") } else { return participants.joined(separator: ", ") }
} }
var isGroupChat: Bool {
participants.count > 1
}
init(from c: Serialized.Conversation) { init(from c: Serialized.Conversation) {
self.id = c.guid self.id = c.guid
self.name = c.displayName self.name = c.displayName
@@ -62,7 +66,7 @@ enum Display
} }
self.attachments = m.attachments.map { attachment in self.attachments = m.attachments.map { attachment in
ImageAttachment(from: attachment, sender: sender) ImageAttachment(from: attachment, dateSent: m.date, sender: sender)
} }
self.sender = sender self.sender = sender
@@ -89,6 +93,7 @@ enum Display
{ {
let id: String let id: String
let sender: Sender let sender: Sender
let dateSent: Date
let data: Serialized.Attachment let data: Serialized.Attachment
var size: CGSize? { var size: CGSize? {
@@ -107,21 +112,37 @@ enum Display
data.previewPath data.previewPath
} }
init(from serialized: Serialized.Attachment, sender: Sender) { init(from serialized: Serialized.Attachment, dateSent: Date, sender: Sender) {
self.id = serialized.guid self.id = serialized.guid
self.sender = sender self.sender = sender
self.data = serialized self.data = serialized
self.dateSent = dateSent
} }
} }
enum Sender enum Sender: Identifiable, Equatable
{ {
case me case me
case counterpart(String) case counterpart(String)
var id: String { displayName }
var isMe: Bool { var isMe: Bool {
if case .me = self { true } else { false } if case .me = self { true } else { false }
} }
var displayName: String {
switch self {
case .me:
"Me"
case .counterpart(let string):
string
}
}
static func ==(lhs: Sender, rhs: Sender) -> Bool {
return lhs.displayName == rhs.displayName
}
} }
} }

View File

@@ -31,7 +31,7 @@ struct SplitView: View
.navigationTitle("Kordophone") .navigationTitle("Kordophone")
.navigationSubtitle(selectedConversation?.displayName ?? "") .navigationSubtitle(selectedConversation?.displayName ?? "")
.onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in .onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in
transcriptViewModel.displayedConversation = newValue.first transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue.first }
} }
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -46,6 +46,7 @@
"size" : "512x512" "size" : "512x512"
}, },
{ {
"filename" : "AppIcon.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "512x512" "size" : "512x512"

View File

@@ -9,16 +9,28 @@ import SwiftUI
struct BubbleView<Content: View>: View struct BubbleView<Content: View>: View
{ {
let isFromMe: Bool let date: Date
let sender: Display.Sender
let content: () -> Content let content: () -> Content
init(isFromMe: Bool, @ViewBuilder content: @escaping () -> Content) { private var isFromMe: Bool { sender.isMe }
self.isFromMe = isFromMe
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.content = content
self.date = date
} }
var body: some View { var body: some View {
VStack { VStack(alignment: isFromMe ? .trailing : .leading) {
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
if isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } if isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) }
@@ -28,20 +40,24 @@ struct BubbleView<Content: View>: View
topLeading: isFromMe ? .dominantCornerRadius : .minorCornerRadius, topLeading: isFromMe ? .dominantCornerRadius : .minorCornerRadius,
bottomLeading: .dominantCornerRadius, bottomLeading: .dominantCornerRadius,
bottomTrailing: .dominantCornerRadius, bottomTrailing: .dominantCornerRadius,
topTrailing: isFromMe ? .minorCornerRadius : .dominantCornerRadius, topTrailing: isFromMe ? .minorCornerRadius : .dominantCornerRadius
)) ))
} }
if !isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } if !isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) }
} }
} }
.help(tooltipDateFormatter.string(from: date))
} }
} }
struct TextBubbleItemView: View struct TextBubbleItemView: View
{ {
let text: String let text: String
let isFromMe: Bool let sender: Display.Sender
let date: Date
private var isFromMe: Bool { sender.isMe }
var body: some View { var body: some View {
let bubbleColor: Color = isFromMe ? .blue : Color(NSColor(name: "grayish", dynamicProvider: { appearance in let bubbleColor: Color = isFromMe ? .blue : Color(NSColor(name: "grayish", dynamicProvider: { appearance in
@@ -49,7 +65,7 @@ struct TextBubbleItemView: View
})) }))
let textColor: Color = isFromMe ? .white : .primary let textColor: Color = isFromMe ? .white : .primary
BubbleView(isFromMe: isFromMe) { BubbleView(sender: sender, date: date) {
HStack { HStack {
Text(text) Text(text)
.foregroundStyle(textColor) .foregroundStyle(textColor)
@@ -65,12 +81,14 @@ struct TextBubbleItemView: View
struct ImageItemView: View struct ImageItemView: View
{ {
let isFromMe: Bool let sender: Display.Sender
let date: Date
let attachment: Display.ImageAttachment let attachment: Display.ImageAttachment
@State private var img: NSImage? @State private var img: NSImage?
@Environment(\.xpcClient) var xpcClient @Environment(\.xpcClient) var xpcClient
private let imageMaxWidth: CGFloat = 340.0
@State private var containerWidth: CGFloat? = nil @State private var containerWidth: CGFloat? = nil
var aspectRatio: CGFloat { var aspectRatio: CGFloat {
@@ -99,13 +117,13 @@ struct ImageItemView: View
} }
var body: some View { var body: some View {
BubbleView(isFromMe: isFromMe) { BubbleView(sender: sender, date: date) {
if let img { if let img {
Image(nsImage: img) Image(nsImage: img)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame( .frame(
maxWidth: containerWidth maxWidth: CGFloat.minimum(imageMaxWidth, containerWidth ?? imageMaxWidth)
) )
} else { } else {
Rectangle() Rectangle()
@@ -139,16 +157,18 @@ struct ImageItemView: View
struct PlaceholderImageItemView: View struct PlaceholderImageItemView: View
{ {
let isFromMe: Bool let sender: Display.Sender
let date: Date
let size: CGSize let size: CGSize
init(isFromMe: Bool, size: CGSize?) { init(sender: Display.Sender, date: Date, size: CGSize?) {
self.isFromMe = isFromMe self.sender = sender
self.date = date
self.size = size ?? CGSize(width: 250.0, height: 100.0) self.size = size ?? CGSize(width: 250.0, height: 100.0)
} }
var body: some View { var body: some View {
BubbleView(isFromMe: isFromMe) { BubbleView(sender: sender, date: date) {
Color.gray Color.gray
.frame(width: size.width, height: size.height) .frame(width: size.width, height: size.height)
} }
@@ -180,6 +200,24 @@ struct DateItemView: View
} }
} }
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 { fileprivate extension CGFloat {
static let dominantCornerRadius = 16.0 static let dominantCornerRadius = 16.0
static let minorCornerRadius = 4.0 static let minorCornerRadius = 4.0

View File

@@ -13,15 +13,25 @@ extension TranscriptView.ViewModel
internal func rebuildDisplayItems(animated: Bool = false) { internal func rebuildDisplayItems(animated: Bool = false) {
var displayItems: [DisplayItem] = [] var displayItems: [DisplayItem] = []
var lastDate: Date = .distantPast var lastDate: Date = .distantPast
var lastSender: Display.Sender? = nil
let client = XPCClient() let client = XPCClient()
let dateAnnotationTimeInterval: TimeInterval = 60 * 60 * 30 // 30m let isGroupChat = displayedConversation?.isGroupChat ?? false
let dateAnnotationTimeInterval: TimeInterval = 60 * 30
for message in messages { for message in messages {
if message.date.timeIntervalSince(lastDate) > dateAnnotationTimeInterval { if message.sender != lastSender {
lastDate = message.date displayItems.append(.spacer(15.0, message.id))
}
let isPastDateThreshold = message.date.timeIntervalSince(lastDate) > dateAnnotationTimeInterval
if isPastDateThreshold {
displayItems.append(.date(message.date)) displayItems.append(.date(message.date))
} }
if isGroupChat && !message.sender.isMe && (isPastDateThreshold || message.sender != lastSender) {
displayItems.append(.senderAttribition(message))
}
for attachment in message.attachments { for attachment in message.attachments {
displayItems.append(.attachment(attachment)) displayItems.append(.attachment(attachment))
@@ -35,6 +45,9 @@ extension TranscriptView.ViewModel
if !message.text.isEmpty { if !message.text.isEmpty {
displayItems.append(.message(message)) displayItems.append(.message(message))
} }
lastSender = message.sender
lastDate = message.date
} }
let animation: Animation? = animated ? .default : nil let animation: Animation? = animated ? .default : nil
@@ -48,6 +61,8 @@ enum DisplayItem: Identifiable
{ {
case message(Display.Message) case message(Display.Message)
case attachment(Display.ImageAttachment) case attachment(Display.ImageAttachment)
case senderAttribition(Display.Message)
case spacer(CGFloat, Display.Message.ID)
case date(Date) case date(Date)
var id: String { var id: String {
@@ -56,8 +71,12 @@ enum DisplayItem: Identifiable
message.id message.id
case .attachment(let attachment): case .attachment(let attachment):
attachment.id attachment.id
case .senderAttribition(let message):
"\(message.sender.displayName) @ \(message.id)"
case .date(let date): case .date(let date):
date.description date.description
case .spacer(let space, let id):
"\(space)=\(id)"
} }
} }
} }

View File

@@ -15,13 +15,13 @@ struct TranscriptView: View
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVStack(spacing: 17.0) { LazyVStack(spacing: 6.0) {
ForEach($model.displayItems.reversed()) { item in ForEach($model.displayItems.reversed()) { item in
displayItemView(item.wrappedValue) displayItemView(item.wrappedValue)
.id(item.id) .id(item.id)
.scaleEffect(CGSize(width: 1.0, height: -1.0)) .scaleEffect(CGSize(width: 1.0, height: -1.0))
.transition( .transition(
.push(from: .bottom) .push(from: .top)
.combined(with: .opacity) .combined(with: .opacity)
) )
} }
@@ -39,7 +39,7 @@ struct TranscriptView: View
case .attachmentDownloaded(let attachmentId): case .attachmentDownloaded(let attachmentId):
model.attachmentDownloaded(id: attachmentId) model.attachmentDownloaded(id: attachmentId)
case .messagesUpdated(let conversationId): case .messagesUpdated(let conversationId):
if conversationId == model.displayedConversation { if let displayedConversation = model.displayedConversation, conversationId == displayedConversation.id {
model.setNeedsReload(animated: true) model.setNeedsReload(animated: true)
} }
case .updateStreamReconnected: case .updateStreamReconnected:
@@ -54,18 +54,24 @@ struct TranscriptView: View
private func displayItemView(_ item: DisplayItem) -> some View { private func displayItemView(_ item: DisplayItem) -> some View {
switch item { switch item {
case .message(let message): case .message(let message):
TextBubbleItemView(text: message.text, isFromMe: message.isFromMe) TextBubbleItemView(text: message.text, sender: message.sender, date: message.date)
case .date(let date): case .date(let date):
DateItemView(date: date) DateItemView(date: date)
case .senderAttribition(let message):
SenderAttributionView(sender: message.sender)
case .spacer(let length, _):
Spacer(minLength: length)
case .attachment(let attachment): case .attachment(let attachment):
if attachment.isPreviewDownloaded { if attachment.isPreviewDownloaded {
ImageItemView( ImageItemView(
isFromMe: attachment.sender.isMe, sender: attachment.sender,
date: attachment.dateSent,
attachment: attachment attachment: attachment
) )
} else { } else {
PlaceholderImageItemView( PlaceholderImageItemView(
isFromMe: attachment.sender.isMe, sender: attachment.sender,
date: attachment.dateSent,
size: attachment.size size: attachment.size
) )
} }
@@ -78,7 +84,7 @@ struct TranscriptView: View
class ViewModel class ViewModel
{ {
var displayItems: [DisplayItem] = [] var displayItems: [DisplayItem] = []
var displayedConversation: Display.Conversation.ID? = nil var displayedConversation: Display.Conversation? = nil
internal var needsReload: NeedsReload = .no internal var needsReload: NeedsReload = .no
internal var messages: [Display.Message] internal var messages: [Display.Message]
@@ -127,7 +133,7 @@ struct TranscriptView: View
guard let displayedConversation else { return } guard let displayedConversation else { return }
do { do {
try await client.markConversationAsRead(conversationId: displayedConversation) try await client.markConversationAsRead(conversationId: displayedConversation.id)
} catch { } catch {
print("Error triggering sync: \(error)") print("Error triggering sync: \(error)")
} }
@@ -137,7 +143,7 @@ struct TranscriptView: View
guard let displayedConversation else { return } guard let displayedConversation else { return }
do { do {
try await client.syncConversation(conversationId: displayedConversation) try await client.syncConversation(conversationId: displayedConversation.id)
} catch { } catch {
print("Error triggering sync: \(error)") print("Error triggering sync: \(error)")
} }
@@ -150,7 +156,7 @@ struct TranscriptView: View
guard let displayedConversation else { return } guard let displayedConversation else { return }
do { do {
let clientMessages = try await client.getMessages(conversationId: displayedConversation) let clientMessages = try await client.getMessages(conversationId: displayedConversation.id)
.map { Display.Message(from: $0) } .map { Display.Message(from: $0) }
self.messages = clientMessages self.messages = clientMessages