diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-10-24 22:47:44 -0700 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-10-24 22:47:44 -0700 |
| commit | cf113ea053334175b93770b025c1f7d22eda6eab (patch) | |
| tree | 8f0037e7e9ad3aa592c8475d962d37eb0c867703 /Hotline/macOS/ChatView.swift | |
| parent | 75ebba6ad7e45e636cd5bb3400d25d040cabad6c (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.swift | 288 |
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): |