aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/Chat
diff options
context:
space:
mode:
Diffstat (limited to 'Hotline/macOS/Chat')
-rw-r--r--Hotline/macOS/Chat/ChatView.swift321
-rw-r--r--Hotline/macOS/Chat/ServerAgreementView.swift78
-rw-r--r--Hotline/macOS/Chat/ServerMessageView.swift33
3 files changed, 432 insertions, 0 deletions
diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift
new file mode 100644
index 0000000..65087c6
--- /dev/null
+++ b/Hotline/macOS/Chat/ChatView.swift
@@ -0,0 +1,321 @@
+import SwiftUI
+
+enum FocusedField: Int, Hashable {
+ case chatInput
+}
+
+struct ChatJoinedMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 4) {
+ 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: 4) {
+ 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 {
+ Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString())
+ }
+ else {
+ Text(message.text)
+ }
+ Spacer()
+ }
+ .lineSpacing(4)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .tint(Color("Link Color"))
+ }
+}
+
+struct ChatView: View {
+ @Environment(Hotline.self) private var model: Hotline
+ @Environment(\.colorScheme) var colorScheme
+ @Environment(\.dismiss) var dismiss
+
+ @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
+
+ 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
+ ScrollView(.vertical) {
+ LazyVStack(alignment: .leading, spacing: 8) {
+
+ ForEach(displayedMessages) { msg in
+ if msg.type == .agreement {
+ VStack(alignment: .center, spacing: 16) {
+ if let bannerImage = self.model.bannerImage {
+ 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)
+ }
+ }
+
+ ServerAgreementView(text: msg.text)
+ }
+ .padding(.vertical, 24)
+ }
+ else if msg.type == .server {
+ ServerMessageView(message: msg.text)
+ }
+ 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 {
+ ChatMessageView(message: msg)
+ }
+ }
+ }
+ .padding()
+
+ VStack(spacing: 0) {}.id(bottomID)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+// .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: self.model.bannerImage) {
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+ .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: $bindModel.chatInput, axis: .vertical)
+ .focused($focusedField, equals: .chatInput)
+ .textFieldStyle(.plain)
+ .lineLimit(1...5)
+ .multilineTextAlignment(.leading)
+ .onSubmit {
+ if !model.chatInput.isEmpty {
+ model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift))
+ }
+ model.chatInput = ""
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+ .frame(maxWidth: .infinity, minHeight: 28)
+ .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
+ .overlay(alignment: .leadingLastTextBaseline) {
+ Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16)
+ }
+ .onContinuousHover { phase in
+ switch phase {
+ case .active(_):
+ NSCursor.iBeam.set()
+ case .ended:
+ NSCursor.arrow.set()
+ break
+ }
+ }
+ .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))
+// .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()
+//
+// self.chatDocument.text = ""
+// for msg in model.chat {
+// if msg.type == .agreement {
+// text.append(msg.text)
+// text.append("\n\n")
+// }
+// else if msg.type == .message {
+// if let username = msg.username {
+// text.append("\(username): \(msg.text)")
+// }
+// else {
+// text.append(msg.text)
+// }
+// text.append("\n")
+// }
+// else if msg.type == .status {
+// text.append(msg.text)
+// text.append("\n")
+// }
+// }
+//
+// if text.isEmpty {
+// return false
+// }
+//
+// self.chatDocument.text = text
+//
+// return true
+// }
+}
+
+#Preview {
+ ChatView()
+ .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+}
diff --git a/Hotline/macOS/Chat/ServerAgreementView.swift b/Hotline/macOS/Chat/ServerAgreementView.swift
new file mode 100644
index 0000000..e72de7d
--- /dev/null
+++ b/Hotline/macOS/Chat/ServerAgreementView.swift
@@ -0,0 +1,78 @@
+import SwiftUI
+
+fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340
+
+struct ServerAgreementView: View {
+ let text: String
+
+ @State private var expandable: Bool = false
+ @State private var expanded: Bool = false
+
+ var body: some View {
+ ScrollView(.vertical) {
+ HStack(alignment: .top) {
+ Spacer()
+ Text(text.convertToAttributedStringWithLinks())
+ .font(.system(size: 12))
+ .fontDesign(.monospaced)
+ .textSelection(.enabled)
+ .tint(Color("Link Color"))
+ .frame(maxWidth: 400)
+ .padding(16)
+ .background(
+ GeometryReader { geometry in
+ Color.clear.onAppear {
+ if geometry.size.height > MAX_AGREEMENT_HEIGHT {
+ expandable = true
+ }
+ else {
+ expandable = false
+ }
+ }
+ }
+ )
+ Spacer()
+ }
+ }
+ .scrollIndicators(.never)
+ .frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT)
+ .scrollBounceBehavior(.basedOnSize)
+#if os(iOS)
+ .background(Color("Agreement Background"))
+#elseif os(macOS)
+ .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
+#endif
+ .overlay(alignment: .bottomTrailing) {
+ ZStack(alignment: .bottomTrailing) {
+ Group {
+ if !expandable || expanded {
+ EmptyView()
+ }
+ else {
+ Button(action: {
+ withAnimation(.easeOut(duration: 0.15)) {
+ expanded = true
+ }
+ }, label: {
+ Image(systemName: "arrow.up.and.down.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .fontWeight(.semibold)
+ .frame(width: 16, height: 16)
+ .foregroundColor(.primary.opacity(0.5))
+ })
+ .buttonStyle(.plain)
+ .buttonBorderShape(.circle)
+ .help("Expand Server Agreement")
+ .padding([.trailing, .bottom], 16)
+ }
+ }
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+}
+
+#Preview {
+ ServerAgreementView(text: "Hello there and welcome to this server.")
+}
diff --git a/Hotline/macOS/Chat/ServerMessageView.swift b/Hotline/macOS/Chat/ServerMessageView.swift
new file mode 100644
index 0000000..6c3c60e
--- /dev/null
+++ b/Hotline/macOS/Chat/ServerMessageView.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+struct ServerMessageView: View {
+ let message: String
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 8) {
+ Image("Server Message")
+ .symbolRenderingMode(.multicolor)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ Text(message)
+ .fontWeight(.semibold)
+ .lineSpacing(4)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ Spacer()
+ }
+ .padding()
+ .frame(maxWidth: .infinity)
+#if os(iOS)
+ .background(Color("Agreement Background"))
+#elseif os(macOS)
+ .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
+#endif
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+}
+
+#Preview {
+ ServerMessageView(message: "This server has something important to say.")
+}