aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/ChatView.swift
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-24 22:47:44 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-24 22:47:44 -0700
commitcf113ea053334175b93770b025c1f7d22eda6eab (patch)
tree8f0037e7e9ad3aa592c8475d962d37eb0c867703 /Hotline/macOS/ChatView.swift
parent75ebba6ad7e45e636cd5bb3400d25d040cabad6c (diff)
A first pass at chat persistence. Also some chat UI cleanup.
Diffstat (limited to 'Hotline/macOS/ChatView.swift')
-rw-r--r--Hotline/macOS/ChatView.swift288
1 files changed, 200 insertions, 88 deletions
diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift
index cd84369..12e6c94 100644
--- a/Hotline/macOS/ChatView.swift
+++ b/Hotline/macOS/ChatView.swift
@@ -4,6 +4,150 @@ enum FocusedField: Int, Hashable {
case chatInput
}
+struct ChatStatusMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 8) {
+ Image(systemName: "arrow.right")
+ .resizable()
+ .scaledToFit()
+ .fontWeight(.semibold)
+ .foregroundStyle(.primary)
+ .frame(width: 14, height: 14)
+
+ Text(message.text)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .textSelection(.disabled)
+
+ Spacer()
+ }
+ .opacity(0.3)
+ }
+}
+
+
+struct ChatJoinedMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 8) {
+ Image(systemName: "arrow.right")
+ .resizable()
+ .scaledToFit()
+ .fontWeight(.semibold)
+ .foregroundStyle(.primary)
+ .frame(width: 12, height: 12)
+
+ Text(message.text)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .fontWeight(.semibold)
+ .textSelection(.disabled)
+
+ Spacer()
+ }
+ .opacity(0.3)
+ }
+}
+
+struct ChatLeftMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 8) {
+ Image(systemName: "arrow.left")
+ .resizable()
+ .scaledToFit()
+ .fontWeight(.semibold)
+ .foregroundStyle(.primary)
+ .frame(width: 12, height: 12)
+
+ Text(message.text)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .fontWeight(.semibold)
+ .textSelection(.disabled)
+
+ Spacer()
+ }
+ .opacity(0.3)
+ }
+}
+
+
+struct ChatDisconnectedMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack {
+ Spacer()
+ }
+ .frame(height: 5)
+ .background(Color.primary)
+ .clipShape(.capsule)
+ .opacity(0.1)
+ }
+}
+
+struct ChatMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ if let username = message.username {
+ // if msg.text.isImageURL() {
+ // HStack(alignment: .bottom) {
+ // Text("**\(username):** ")
+ //
+ // let imageURL = URL(string: msg.text)!
+ // AsyncImage(url: imageURL) { phase in
+ // switch phase {
+ // case .failure:
+ // Text(LocalizedStringKey(msg.text.convertLinksToMarkdown()))
+ // .lineSpacing(4)
+ // .multilineTextAlignment(.leading)
+ // .textSelection(.enabled)
+ // .tint(Color("Link Color"))
+ // case .success(let img):
+ // Link(destination: imageURL) {
+ // img
+ // .resizable()
+ // .scaledToFit()
+ // .frame(maxWidth: 250, maxHeight: 150, alignment: .leading)
+ // .onAppear {
+ // reader.scrollTo(bottomID, anchor: .bottom)
+ // }
+ // }
+ // default:
+ // ProgressView().controlSize(.small)
+ // }
+ // }
+ //
+ // Spacer()
+ // }
+ // }
+ // else {
+ Text(LocalizedStringKey("**\(username):** \(message.text)".convertingLinksToMarkdown()))
+ .lineSpacing(4)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .tint(Color("Link Color"))
+ // }
+ }
+ else {
+ Text(message.text)
+ .lineSpacing(4)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .tint(Color("Link Color"))
+ }
+ Spacer()
+ }
+ }
+}
+
struct ChatView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.colorScheme) var colorScheme
@@ -20,7 +164,7 @@ struct ChatView: View {
@State private var showingExporter: Bool = false
@State private var chatDocument: TextFile = TextFile()
-
+
var body: some View {
NavigationStack {
ScrollViewReader { reader in
@@ -29,99 +173,64 @@ struct ChatView: View {
// MARK: Scroll View
GeometryReader { gm in
ScrollView(.vertical) {
- LazyVStack(alignment: .leading) {
+ LazyVStack(alignment: .leading, spacing: 8) {
ForEach(model.chat) { msg in
-
- // MARK: Agreement
if msg.type == .agreement {
-#if os(iOS)
+ VStack(alignment: .center, spacing: 16) {
if let bannerImage = self.model.bannerImage {
- Image(uiImage: bannerImage)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: 468.0)
- .clipShape(RoundedRectangle(cornerRadius: 3))
+ HStack(spacing: 0) {
+ Spacer(minLength: 0)
+ ZStack {
+ Image(nsImage: bannerImage)
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: 468.0)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .offset(y: 1.5)
+ .blur(radius: 4)
+ .opacity(0.2)
+
+ Image(nsImage: bannerImage)
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: 468.0)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+
+ Spacer(minLength: 0)
+ }
}
-#endif
- ServerAgreementView(text: msg.text)
- .padding(.bottom, 16)
+
+ ServerAgreementView(text: msg.text)
+ }
+ .padding(.vertical, 24)
}
// MARK: Server Message
else if msg.type == .server {
ServerMessageView(message: msg.text)
- .padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
}
// MARK: Status
else if msg.type == .status {
- HStack {
- Spacer()
- Text(msg.text)
- .lineLimit(1)
- .truncationMode(.middle)
- .textSelection(.disabled)
- .opacity(0.3)
- Spacer()
- }
- .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
+ ChatStatusMessageView(message: msg)
+ }
+ else if msg.type == .joined {
+ ChatJoinedMessageView(message: msg)
+ }
+ else if msg.type == .left {
+ ChatLeftMessageView(message: msg)
+ }
+ else if msg.type == .signOut {
+ ChatDisconnectedMessageView(message: msg)
+ .padding(.vertical, 24)
}
else {
- HStack(alignment: .firstTextBaseline) {
- if let username = msg.username {
-// if msg.text.isImageURL() {
-// HStack(alignment: .bottom) {
-// Text("**\(username):** ")
-//
-// let imageURL = URL(string: msg.text)!
-// AsyncImage(url: imageURL) { phase in
-// switch phase {
-// case .failure:
-// Text(LocalizedStringKey(msg.text.convertLinksToMarkdown()))
-// .lineSpacing(4)
-// .multilineTextAlignment(.leading)
-// .textSelection(.enabled)
-// .tint(Color("Link Color"))
-// case .success(let img):
-// Link(destination: imageURL) {
-// img
-// .resizable()
-// .scaledToFit()
-// .frame(maxWidth: 250, maxHeight: 150, alignment: .leading)
-// .onAppear {
-// reader.scrollTo(bottomID, anchor: .bottom)
-// }
-// }
-// default:
-// ProgressView().controlSize(.small)
-// }
-// }
-//
-// Spacer()
-// }
-// }
-// else {
- Text(LocalizedStringKey("**\(username):** \(msg.text)".convertingLinksToMarkdown()))
- .lineSpacing(4)
- .multilineTextAlignment(.leading)
- .textSelection(.enabled)
- .tint(Color("Link Color"))
-// }
- }
- else {
- Text(msg.text)
- .lineSpacing(4)
- .multilineTextAlignment(.leading)
- .textSelection(.enabled)
- .tint(Color("Link Color"))
- }
- Spacer()
- }
- .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
+ ChatMessageView(message: msg)
}
}
}
.padding()
-
+
VStack(spacing: 0) {}.id(bottomID)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -136,6 +245,9 @@ struct ChatView: View {
.onChange(of: gm.size) {
reader.scrollTo(bottomID, anchor: .bottom)
}
+ .onChange(of: self.model.bannerImage) {
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
}
// MARK: Input Divider
@@ -160,7 +272,7 @@ struct ChatView: View {
.frame(maxWidth: .infinity, minHeight: 28)
.padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
.overlay(alignment: .leadingLastTextBaseline) {
- Image(systemName: "chevron.right").opacity(0.4).offset(x: 16)
+ Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16)
}
.onContinuousHover { phase in
switch phase {
@@ -178,17 +290,17 @@ struct ChatView: View {
}
}
.background(Color(nsColor: .textBackgroundColor))
-// .toolbar {
-// ToolbarItem(placement: .primaryAction) {
-// Button {
-// if prepareChatDocument() {
-// showingExporter = true
-// }
-// } label: {
-// Image(systemName: "square.and.arrow.up")
-// }.help("Save Chat...")
-// }
-// }
+ // .toolbar {
+ // ToolbarItem(placement: .primaryAction) {
+ // Button {
+ // if prepareChatDocument() {
+ // showingExporter = true
+ // }
+ // } label: {
+ // Image(systemName: "square.and.arrow.up")
+ // }.help("Save Chat...")
+ // }
+ // }
.fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in
switch result {
case .success(let url):