aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/ChatView.swift
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-25 16:46:52 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-25 16:46:52 -0700
commit3b3b965842c47939ca54d0d1cbdf469346847f14 (patch)
tree22b5ee17d5fa8196a2d6b1bd95d7749c71cd69bc /Hotline/macOS/ChatView.swift
parent53686e30592fee566585e391738adee8b2ef2137 (diff)
Move chat input string to view model so its not lost when switching between tabs. Add chat search/filtering with suppport for live filtering.
Diffstat (limited to 'Hotline/macOS/ChatView.swift')
-rw-r--r--Hotline/macOS/ChatView.swift120
1 files changed, 77 insertions, 43 deletions
diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift
index 2ad9816..65087c6 100644
--- a/Hotline/macOS/ChatView.swift
+++ b/Hotline/macOS/ChatView.swift
@@ -91,30 +91,42 @@ struct ChatView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
-
- @State var input: String = ""
+
@State private var scrollPos: Int?
@State private var contentHeight: CGFloat = 0
-
+
+ @State private var searchQuery: String = ""
+ @State private var searchResults: [ChatMessage] = []
+ @State private var isSearching: Bool = false
+
@FocusState private var focusedField: FocusedField?
-
+
@Namespace var bottomID
-
- @State private var showingExporter: Bool = false
-
- @State private var chatDocument: TextFile = TextFile()
-
+
+ private var bindableModel: Bindable<Hotline> {
+ Bindable(model)
+ }
+
+// @State private var showingExporter: Bool = false
+//
+// @State private var chatDocument: TextFile = TextFile()
+
+ var displayedMessages: [ChatMessage] {
+ searchQuery.isEmpty ? model.chat : searchResults
+ }
+
var body: some View {
+ @Bindable var bindModel = model
+
NavigationStack {
ScrollViewReader { reader in
VStack(alignment: .leading, spacing: 0) {
-
+
// MARK: Scroll View
- GeometryReader { gm in
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 8) {
-
- ForEach(model.chat) { msg in
+
+ ForEach(displayedMessages) { msg in
if msg.type == .agreement {
VStack(alignment: .center, spacing: 16) {
if let bannerImage = self.model.bannerImage {
@@ -168,37 +180,46 @@ struct ChatView: View {
VStack(spacing: 0) {}.id(bottomID)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .defaultScrollAnchor(.bottom)
+// .defaultScrollAnchor(.bottom, for: .initialOffset)
+ .defaultScrollAnchor(.bottom, for: .alignment)
+ .defaultScrollAnchor(.bottom, for: .sizeChanges)
.onChange(of: model.chat.count) {
+ // Re-run search when new messages arrive to keep filter active
+ if !searchQuery.isEmpty {
+ performSearch()
+ }
reader.scrollTo(bottomID, anchor: .bottom)
model.markPublicChatAsRead()
}
.onAppear {
reader.scrollTo(bottomID, anchor: .bottom)
+ self.focusedField = .chatInput
}
- .onChange(of: gm.size) {
+ .onChange(of: self.model.bannerImage) {
reader.scrollTo(bottomID, anchor: .bottom)
}
- .onChange(of: self.model.bannerImage) {
+ .onChange(of: searchQuery) {
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+ .onChange(of: isSearching) {
reader.scrollTo(bottomID, anchor: .bottom)
}
- }
// MARK: Input Divider
Divider()
// MARK: Input Bar
HStack(alignment: .lastTextBaseline, spacing: 0) {
- TextField("", text: $input, axis: .vertical)
+ TextField("", text: $bindModel.chatInput, axis: .vertical)
.focused($focusedField, equals: .chatInput)
.textFieldStyle(.plain)
.lineLimit(1...5)
.multilineTextAlignment(.leading)
.onSubmit {
- if !self.input.isEmpty {
- model.sendChat(self.input, announce: NSEvent.modifierFlags.contains(.shift))
+ if !model.chatInput.isEmpty {
+ model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift))
}
- self.input = ""
+ model.chatInput = ""
}
.frame(maxWidth: .infinity)
.padding()
@@ -217,36 +238,49 @@ struct ChatView: View {
break
}
}
- .onTapGesture {
+ .onTapGesture(count: 1) {
focusedField = .chatInput
}
}
}
+ .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search")
+ .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden())
}
.background(Color(nsColor: .textBackgroundColor))
- // .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):
- print("Saved to \(url)")
-
- case .failure(let error):
- print(error.localizedDescription)
- }
- self.chatDocument.text = ""
+// .navigationTitle(model.serverTitle)
+ .onChange(of: searchQuery) {
+ performSearch()
+ }
+// .toolbar {
+// ToolbarItem(placement: .primaryAction) {
+// Button {
+// 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):
+// print("Saved to \(url)")
+//
+// case .failure(let error):
+// print(error.localizedDescription)
+// }
+// self.chatDocument.text = ""
+// }
+ }
+
+ private func performSearch() {
+ guard !searchQuery.isEmpty else {
+ searchResults = []
+ return
}
+
+ searchResults = model.searchChat(query: searchQuery)
}
-
+
// private func prepareChatDocument() -> Bool {
// var text: String = String()
//