App icon, group member annotations, variable spacing
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -46,6 +46,7 @@
|
|||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user