diff --git a/kordophone2/Models.swift b/kordophone2/Models.swift index 223a80e..a53ce69 100644 --- a/kordophone2/Models.swift +++ b/kordophone2/Models.swift @@ -23,6 +23,10 @@ enum Display else { return participants.joined(separator: ", ") } } + var isGroupChat: Bool { + participants.count > 1 + } + init(from c: Serialized.Conversation) { self.id = c.guid self.name = c.displayName @@ -62,7 +66,7 @@ enum Display } self.attachments = m.attachments.map { attachment in - ImageAttachment(from: attachment, sender: sender) + ImageAttachment(from: attachment, dateSent: m.date, sender: sender) } self.sender = sender @@ -89,6 +93,7 @@ enum Display { let id: String let sender: Sender + let dateSent: Date let data: Serialized.Attachment var size: CGSize? { @@ -107,21 +112,37 @@ enum Display data.previewPath } - init(from serialized: Serialized.Attachment, sender: Sender) { + init(from serialized: Serialized.Attachment, dateSent: Date, sender: Sender) { self.id = serialized.guid self.sender = sender self.data = serialized + self.dateSent = dateSent } } - enum Sender + enum Sender: Identifiable, Equatable { case me case counterpart(String) + var id: String { displayName } + var isMe: Bool { 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 + } } } diff --git a/kordophone2/SplitView.swift b/kordophone2/SplitView.swift index d37a603..5a29adf 100644 --- a/kordophone2/SplitView.swift +++ b/kordophone2/SplitView.swift @@ -31,7 +31,7 @@ struct SplitView: View .navigationTitle("Kordophone") .navigationSubtitle(selectedConversation?.displayName ?? "") .onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in - transcriptViewModel.displayedConversation = newValue.first + transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue.first } } } } diff --git a/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..36ef03e Binary files /dev/null and b/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json b/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..bc26afc 100644 --- a/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/kordophone2/Supporting Files/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -46,6 +46,7 @@ "size" : "512x512" }, { + "filename" : "AppIcon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/kordophone2/Transcript/TranscriptDisplayItemViews.swift b/kordophone2/Transcript/TranscriptDisplayItemViews.swift index f6d6f16..7459ded 100644 --- a/kordophone2/Transcript/TranscriptDisplayItemViews.swift +++ b/kordophone2/Transcript/TranscriptDisplayItemViews.swift @@ -9,16 +9,28 @@ import SwiftUI struct BubbleView: View { - let isFromMe: Bool + let date: Date + let sender: Display.Sender let content: () -> Content - init(isFromMe: Bool, @ViewBuilder content: @escaping () -> Content) { - self.isFromMe = isFromMe + 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 { + VStack(alignment: isFromMe ? .trailing : .leading) { HStack(alignment: .bottom) { if isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } @@ -28,20 +40,24 @@ struct BubbleView: View topLeading: isFromMe ? .dominantCornerRadius : .minorCornerRadius, bottomLeading: .dominantCornerRadius, bottomTrailing: .dominantCornerRadius, - topTrailing: isFromMe ? .minorCornerRadius : .dominantCornerRadius, + topTrailing: isFromMe ? .minorCornerRadius : .dominantCornerRadius )) } if !isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } } } + .help(tooltipDateFormatter.string(from: date)) } } struct TextBubbleItemView: View { let text: String - let isFromMe: Bool + 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 @@ -49,7 +65,7 @@ struct TextBubbleItemView: View })) let textColor: Color = isFromMe ? .white : .primary - BubbleView(isFromMe: isFromMe) { + BubbleView(sender: sender, date: date) { HStack { Text(text) .foregroundStyle(textColor) @@ -65,12 +81,14 @@ struct TextBubbleItemView: View struct ImageItemView: View { - let isFromMe: Bool + 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 { @@ -99,13 +117,13 @@ struct ImageItemView: View } var body: some View { - BubbleView(isFromMe: isFromMe) { + BubbleView(sender: sender, date: date) { if let img { Image(nsImage: img) .resizable() .scaledToFit() .frame( - maxWidth: containerWidth + maxWidth: CGFloat.minimum(imageMaxWidth, containerWidth ?? imageMaxWidth) ) } else { Rectangle() @@ -139,16 +157,18 @@ struct ImageItemView: View struct PlaceholderImageItemView: View { - let isFromMe: Bool + let sender: Display.Sender + let date: Date let size: CGSize - init(isFromMe: Bool, size: CGSize?) { - self.isFromMe = isFromMe + 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(isFromMe: isFromMe) { + BubbleView(sender: sender, date: date) { Color.gray .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 { static let dominantCornerRadius = 16.0 static let minorCornerRadius = 4.0 diff --git a/kordophone2/Transcript/TranscriptDisplayItems.swift b/kordophone2/Transcript/TranscriptDisplayItems.swift index ea72a8f..f98a1e0 100644 --- a/kordophone2/Transcript/TranscriptDisplayItems.swift +++ b/kordophone2/Transcript/TranscriptDisplayItems.swift @@ -13,15 +13,25 @@ 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 dateAnnotationTimeInterval: TimeInterval = 60 * 60 * 30 // 30m + let isGroupChat = displayedConversation?.isGroupChat ?? false + let dateAnnotationTimeInterval: TimeInterval = 60 * 30 for message in messages { - if message.date.timeIntervalSince(lastDate) > dateAnnotationTimeInterval { - lastDate = message.date + 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)) @@ -35,6 +45,9 @@ extension TranscriptView.ViewModel if !message.text.isEmpty { displayItems.append(.message(message)) } + + lastSender = message.sender + lastDate = message.date } let animation: Animation? = animated ? .default : nil @@ -48,6 +61,8 @@ 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 { @@ -56,8 +71,12 @@ enum DisplayItem: Identifiable 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)" } } } diff --git a/kordophone2/Transcript/TranscriptView.swift b/kordophone2/Transcript/TranscriptView.swift index 0fb6ff6..a5076c0 100644 --- a/kordophone2/Transcript/TranscriptView.swift +++ b/kordophone2/Transcript/TranscriptView.swift @@ -15,13 +15,13 @@ struct TranscriptView: View var body: some View { ScrollView { - LazyVStack(spacing: 17.0) { + 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: .bottom) + .push(from: .top) .combined(with: .opacity) ) } @@ -39,7 +39,7 @@ struct TranscriptView: View case .attachmentDownloaded(let attachmentId): model.attachmentDownloaded(id: attachmentId) case .messagesUpdated(let conversationId): - if conversationId == model.displayedConversation { + if let displayedConversation = model.displayedConversation, conversationId == displayedConversation.id { model.setNeedsReload(animated: true) } case .updateStreamReconnected: @@ -54,18 +54,24 @@ struct TranscriptView: View private func displayItemView(_ item: DisplayItem) -> some View { switch item { 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): 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( - isFromMe: attachment.sender.isMe, + sender: attachment.sender, + date: attachment.dateSent, attachment: attachment ) } else { PlaceholderImageItemView( - isFromMe: attachment.sender.isMe, + sender: attachment.sender, + date: attachment.dateSent, size: attachment.size ) } @@ -78,7 +84,7 @@ struct TranscriptView: View class ViewModel { var displayItems: [DisplayItem] = [] - var displayedConversation: Display.Conversation.ID? = nil + var displayedConversation: Display.Conversation? = nil internal var needsReload: NeedsReload = .no internal var messages: [Display.Message] @@ -127,7 +133,7 @@ struct TranscriptView: View guard let displayedConversation else { return } do { - try await client.markConversationAsRead(conversationId: displayedConversation) + try await client.markConversationAsRead(conversationId: displayedConversation.id) } catch { print("Error triggering sync: \(error)") } @@ -137,7 +143,7 @@ struct TranscriptView: View guard let displayedConversation else { return } do { - try await client.syncConversation(conversationId: displayedConversation) + try await client.syncConversation(conversationId: displayedConversation.id) } catch { print("Error triggering sync: \(error)") } @@ -150,7 +156,7 @@ struct TranscriptView: View guard let displayedConversation else { return } do { - let clientMessages = try await client.getMessages(conversationId: displayedConversation) + let clientMessages = try await client.getMessages(conversationId: displayedConversation.id) .map { Display.Message(from: $0) } self.messages = clientMessages