1// ChatView.swift
2// SophaxChat
3//
4// Individual conversation view with end-to-end encrypted messaging.
5
6import SwiftUI
7import SophaxChatCore
8import CoreImage.CIFilterBuiltins
9import PhotosUI
10import AVFoundation
11
12// MARK: - Disappearing messages interval
13
14enum DisappearingInterval: String, CaseIterable, Identifiable {
15 case off = "Off"
16 case thirtySeconds = "30 seconds"
17 case fiveMinutes = "5 minutes"
18 case oneHour = "1 hour"
19 case oneDay = "24 hours"
20 case oneWeek = "7 days"
21
22 var id: String { rawValue }
23
24 var seconds: TimeInterval? {
25 switch self {
26 case .off: return nil
27 case .thirtySeconds: return 30
28 case .fiveMinutes: return 5 * 60
29 case .oneHour: return 60 * 60
30 case .oneDay: return 24 * 60 * 60
31 case .oneWeek: return 7 * 24 * 60 * 60
32 }
33 }
34
35 var icon: String {
36 self == .off ? "timer" : "timer.circle.fill"
37 }
38}
39
40struct ChatView: View {
41 @EnvironmentObject var appState: AppState
42 @Environment(\.dismiss) private var dismiss
43 let peer: KnownPeer
44
45 @State private var vm: ChatViewModel
46 @FocusState private var isInputFocused: Bool
47
48 // Scroll-to-bottom tracking
49 @State private var atBottom = true
50 @State private var newWhileScrolled = 0
51
52 // Pure UI flags (sheet/dialog visibility)
53 @State private var showingSafetyNumber = false
54 @State private var showingBlockConfirm = false
55 @State private var showingCamera = false
56 @State private var showingFilePicker = false
57 @State private var showingRenameAlert = false
58 @State private var showingDeadDrop = false
59 @State private var showAISheet = false
60
61 // PTT recording
62 @StateObject private var voiceRecorder = VoiceRecorder()
63
64 // TOFU nudge dismiss state persisted per peer
65 @AppStorage private var verifyNudgeDismissed: Bool
66
67 init(peer: KnownPeer) {
68 self.peer = peer
69 _vm = State(initialValue: ChatViewModel(peerID: peer.id))
70 _verifyNudgeDismissed = AppStorage(wrappedValue: false, "verifyNudgeDismissed.\(peer.id)")
71 }
72
73 private var messages: [StoredMessage] {
74 appState.messages[peer.id] ?? []
75 }
76
77 private var displayedMessages: [StoredMessage] {
78 guard vm.isSearching, !vm.searchQuery.isEmpty else { return messages }
79 return messages.filter { $0.body.localizedCaseInsensitiveContains(vm.searchQuery) }
80 }
81
82 private var isOnline: Bool {
83 appState.onlinePeers.contains(peer.id)
84 }
85
86 private var hasIncomingMessage: Bool {
87 messages.contains { $0.direction == .received }
88 }
89
90 var body: some View {
91 @Bindable var vm = vm
92 chatContent
93 .sheet(isPresented: $showingSafetyNumber) {
94 SafetyNumberView(peer: peer)
95 }
96 .confirmationDialog(
97 "Block \(peer.username)?",
98 isPresented: $showingBlockConfirm,
99 titleVisibility: .visible
100 ) {
101 Button("Block", role: .destructive) {
102 appState.blockPeer(peerID: peer.id)
103 dismiss()
104 }
105 Button("Cancel", role: .cancel) {}
106 } message: {
107 Text("You won't receive messages from this person.")
108 }
109 .alert("Rename Contact", isPresented: $showingRenameAlert) {
110 TextField("Name", text: $vm.renameText)
111 .autocorrectionDisabled()
112 Button("Save") { appState.setAlias(vm.renameText.isEmpty ? nil : vm.renameText, for: peer.id) }
113 Button("Reset") { appState.setAlias(nil, for: peer.id) }
114 Button("Cancel", role: .cancel) {}
115 } message: {
116 Text("Set a custom name for \(peer.username).")
117 }
118 .sheet(item: $vm.forwardingMessage) { message in
119 ForwardPickerView(message: message)
120 .environmentObject(appState)
121 }
122 .sheet(isPresented: $showAISheet) {
123 NavigationStack {
124 AIAssistantView(seedPrompt: vm.aiSeedPrompt)
125 }
126 }
127 .alert("Dead Drop", isPresented: $showingDeadDrop) {
128 TextField("Message", text: $vm.deadDropText)
129 .autocorrectionDisabled()
130 Button("Send via Mesh") {
131 let text = vm.deadDropText.trimmingCharacters(in: .whitespaces)
132 guard !text.isEmpty else { return }
133 appState.sendDeadDrop(text: text, toPeerID: peer.id)
134 vm.deadDropText = ""
135 }
136 Button("Cancel", role: .cancel) { vm.deadDropText = "" }
137 } message: {
138 Text("Your message will be flooded over the mesh network. \(peer.username) will receive it when they come online nearby — no internet needed.")
139 }
140 }
141
142 // Extracted so the Swift type checker doesn't time out on one giant body expression.
143 @ViewBuilder private var pinnedMessageBanner: some View {
144 if let msgID = appState.pinnedMessages[peer.id],
145 let msg = messages.first(where: { $0.id == msgID }) {
146 HStack(spacing: 8) {
147 Image(systemName: "pin.fill")
148 .font(.caption2)
149 .foregroundStyle(.orange)
150 Text(msg.body.isEmpty ? "Attachment" : msg.body)
151 .font(.caption)
152 .lineLimit(1)
153 .foregroundStyle(.primary)
154 Spacer()
155 Button {
156 appState.unpinMessage(inConversation: peer.id)
157 } label: {
158 Image(systemName: "xmark")
159 .font(.caption2)
160 .foregroundStyle(.secondary)
161 }
162 }
163 .padding(.horizontal, 14)
164 .padding(.vertical, 6)
165 .background(Color.orange.opacity(0.08))
166 }
167 }
168
169 private var chatContent: some View {
170 @Bindable var vm = vm
171 return VStack(spacing: 0) {
172 pinnedMessageBanner
173 messageList
174 Divider()
175 searchBar
176 replyBar
177 editBar
178 warningBanners
179 inputBar
180 }
181 .navigationTitle(appState.displayName(for: peer))
182 .navigationBarTitleDisplayMode(.inline)
183 .toolbar {
184 ToolbarItem(placement: .topBarTrailing) {
185 chatToolbar
186 }
187 }
188 }
189
190 @ViewBuilder private var chatToolbar: some View {
191 @Bindable var vm = vm
192 HStack(spacing: 12) {
193 // AI summarize
194 if #available(iOS 26.0, *) {
195 Button {
196 let displayName = appState.displayName(for: peer)
197 let msgs = messages.suffix(20).map { msg in
198 (msg.direction == .sent ? "Me" : displayName) + ": " + msg.body
199 }.joined(separator: "\n")
200 vm.aiSeedPrompt = "Summarize this conversation in 3 concise bullet points:\n\n\(msgs)"
201 showAISheet = true
202 } label: {
203 Image(systemName: "sparkles")
204 }
205 .disabled(messages.isEmpty)
206 }
207
208 // Search toggle
209 Button {
210 withAnimation { vm.isSearching.toggle() }
211 if !vm.isSearching { vm.searchQuery = "" }
212 } label: {
213 Image(systemName: vm.isSearching ? "xmark.circle" : "magnifyingglass")
214 }
215
216 // Online indicator + dead drop when offline
217 if isOnline {
218 HStack(spacing: 4) {
219 Circle().fill(.green).frame(width: 8, height: 8)
220 Text("Online")
221 .font(.caption2)
222 .foregroundStyle(.secondary)
223 }
224 } else {
225 Button {
226 showingDeadDrop = true
227 } label: {
228 HStack(spacing: 4) {
229 Circle().fill(Color.gray).frame(width: 8, height: 8)
230 Text("Dead Drop")
231 .font(.caption2)
232 }
233 }
234 .foregroundStyle(.secondary)
235 }
236
237 // Disappearing messages
238 Menu {
239 ForEach(DisappearingInterval.allCases) { interval in
240 Button {
241 vm.disappearingInterval = interval
242 vm.saveDisappearing()
243 } label: {
244 if vm.disappearingInterval == interval {
245 Label(interval.rawValue, systemImage: "checkmark")
246 } else {
247 Text(interval.rawValue)
248 }
249 }
250 }
251 } label: {
252 Image(systemName: vm.disappearingInterval.icon)
253 .foregroundStyle(vm.disappearingInterval == .off ? Color.primary : Color.orange)
254 }
255
256 // Safety number + more actions
257 Menu {
258 Button {
259 showingSafetyNumber = true
260 } label: {
261 if appState.isVerified(peer.id, currentSafetyNumber: peer.safetyNumber) {
262 Label("Identity Verified", systemImage: "checkmark.shield.fill")
263 } else if appState.hasKeyChanged(for: peer.id, currentSafetyNumber: peer.safetyNumber) {
264 Label("Key Changed — Verify Now!", systemImage: "exclamationmark.shield.fill")
265 } else {
266 Label("Verify Identity", systemImage: "checkmark.shield")
267 }
268 }
269 Button {
270 vm.renameText = appState.peerAliases[peer.id] ?? ""
271 showingRenameAlert = true
272 } label: {
273 Label("Rename Contact", systemImage: "pencil")
274 }
275 Divider()
276 Button(role: .destructive) {
277 showingBlockConfirm = true
278 } label: {
279 Label("Block \(peer.username)", systemImage: "nosign")
280 }
281 } label: {
282 Image(systemName: "ellipsis.circle")
283 }
284 }
285 }
286
287 @ViewBuilder private var messageList: some View {
288 @Bindable var vm = vm
289 ScrollViewReader { proxy in
290 ScrollView {
291
292 LazyVStack(spacing: 8) {
293 ForEach(Array(displayedMessages.enumerated()), id: \.element.id) { index, message in
294 let prevDate = index > 0 ? displayedMessages[index - 1].timestamp : nil
295 if prevDate == nil || !Calendar.current.isDate(message.timestamp, inSameDayAs: prevDate!) {
296 DateSeparatorView(date: message.timestamp)
297 }
298 MessageBubbleView(
299 message: message,
300 onDelete: { appState.deleteMessage(message) },
301 onReply: { withAnimation { vm.replyingTo = message } },
302 onForward: { vm.forwardingMessage = message },
303 onEdit: {
304 vm.messageText = message.body
305 withAnimation { vm.editingMessage = message }
306 isInputFocused = true
307 },
308 onPin: {
309 if appState.pinnedMessages[peer.id] == message.id {
310 appState.unpinMessage(inConversation: peer.id)
311 } else {
312 appState.pinMessage(message.id, inConversation: peer.id)
313 }
314 },
315 onAIAction: { prompt in
316 vm.aiSeedPrompt = prompt
317 showAISheet = true
318 },
319 showTimestamp: showTimestamp(at: index, in: displayedMessages),
320 highlightText: vm.isSearching ? vm.searchQuery : nil
321 )
322 .id(message.id)
323 }
324 if appState.typingPeers.contains(peer.id) {
325 TypingBubbleView().id("typing-indicator")
326 }
327 Color.clear.frame(height: 1)
328 .id("bottom")
329 .background(
330 GeometryReader { geo in
331 Color.clear.preference(
332 key: BottomOffsetKey.self,
333 value: geo.frame(in: .named("chatScroll")).maxY
334 )
335 }
336 )
337 }
338 .padding(.horizontal, 16)
339 .padding(.top, 8)
340 .padding(.bottom, 12)
341 }
342 .coordinateSpace(name: "chatScroll")
343 .onPreferenceChange(BottomOffsetKey.self) { maxY in
344 let wasAtBottom = atBottom
345 atBottom = maxY <= UIScreen.main.bounds.height + 80
346 if atBottom && !wasAtBottom {
347 newWhileScrolled = 0
348 appState.markAsRead(peerID: peer.id)
349 }
350 }
351 .onChange(of: messages.count) { old, new in
352 if atBottom {
353 withAnimation { proxy.scrollTo("bottom", anchor: .bottom) }
354 appState.markAsRead(peerID: peer.id)
355 } else {
356 newWhileScrolled += max(0, new - old)
357 }
358 }
359 .onChange(of: appState.typingPeers.contains(peer.id)) { _, isTyping in
360 if isTyping { withAnimation { proxy.scrollTo("bottom", anchor: .bottom) } }
361 }
362 .onAppear {
363 proxy.scrollTo("bottom", anchor: .bottom)
364 appState.markAsRead(peerID: peer.id)
365 }
366 .overlay(alignment: .bottomTrailing) {
367 if !atBottom {
368 Button {
369 withAnimation { proxy.scrollTo("bottom", anchor: .bottom) }
370 atBottom = true
371 newWhileScrolled = 0
372 appState.markAsRead(peerID: peer.id)
373 } label: {
374 ZStack(alignment: .topTrailing) {
375 Circle()
376 .fill(.bar)
377 .shadow(color: .black.opacity(0.15), radius: 4, y: 2)
378 .frame(width: 40, height: 40)
379 Image(systemName: "chevron.down")
380 .font(.system(size: 15, weight: .semibold))
381 .foregroundStyle(.secondary)
382 .frame(width: 40, height: 40)
383 if newWhileScrolled > 0 {
384 Text("\(min(newWhileScrolled, 99))")
385 .font(.caption2.bold())
386 .foregroundStyle(.white)
387 .padding(.horizontal, 5)
388 .padding(.vertical, 2)
389 .background(Color.accentColor, in: Capsule())
390 .offset(x: 10, y: -10)
391 }
392 }
393 }
394 .padding(.trailing, 16)
395 .padding(.bottom, 8)
396 .transition(.scale(scale: 0.8).combined(with: .opacity))
397 }
398 }
399 .animation(.spring(response: 0.3), value: atBottom)
400 .privacyScreen()
401 .onDisappear {
402 // Never persist drafts when app lock is enabled — UserDefaults is
403 // unencrypted and included in device backups. When lock is off the
404 // device is already considered accessible, so drafts are safe to keep.
405 if appState.appLockEnabled {
406 vm.clearDraft()
407 } else {
408 vm.saveDraft()
409 }
410 }
411 }
412 }
413
414 @ViewBuilder private var searchBar: some View {
415 @Bindable var vm = vm
416 if vm.isSearching {
417 HStack(spacing: 8) {
418 Image(systemName: "magnifyingglass").foregroundStyle(.tertiary)
419 TextField("Search messages…", text: $vm.searchQuery)
420 .autocorrectionDisabled()
421 .textInputAutocapitalization(.never)
422 if !vm.searchQuery.isEmpty {
423 Button { vm.searchQuery = "" } label: {
424 Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary)
425 }
426 }
427 }
428 .padding(.horizontal, 16)
429 .padding(.vertical, 8)
430 .background(.bar)
431 .transition(.move(edge: .top).combined(with: .opacity))
432 }
433 }
434
435 @ViewBuilder private var replyBar: some View {
436 @Bindable var vm = vm
437 if let replying = vm.replyingTo {
438 HStack(spacing: 10) {
439 Rectangle()
440 .fill(Color.accentColor)
441 .frame(width: 3)
442 .clipShape(Capsule())
443 VStack(alignment: .leading, spacing: 2) {
444 Text(replying.direction == .sent ? "Reply to yourself" : "Reply to \(appState.displayName(for: peer))")
445 .font(.caption.bold())
446 .foregroundStyle(Color.accentColor)
447 Text(replying.body)
448 .font(.caption)
449 .foregroundStyle(.secondary)
450 .lineLimit(1)
451 }
452 Spacer()
453 Button { withAnimation { vm.replyingTo = nil } } label: {
454 Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary)
455 }
456 }
457 .padding(.horizontal, 16)
458 .padding(.vertical, 8)
459 .background(.bar)
460 .transition(.move(edge: .bottom).combined(with: .opacity))
461 }
462 }
463
464 @ViewBuilder private var editBar: some View {
465 @Bindable var vm = vm
466 if vm.editingMessage != nil {
467 HStack(spacing: 10) {
468 Image(systemName: "pencil")
469 .foregroundStyle(Color.accentColor)
470 VStack(alignment: .leading, spacing: 2) {
471 Text("Edit message")
472 .font(.caption.bold())
473 .foregroundStyle(Color.accentColor)
474 Text(vm.editingMessage?.body ?? "")
475 .font(.caption)
476 .foregroundStyle(.secondary)
477 .lineLimit(1)
478 }
479 Spacer()
480 Button {
481 withAnimation { vm.cancelEdit() }
482 } label: {
483 Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary)
484 }
485 }
486 .padding(.horizontal, 16)
487 .padding(.vertical, 8)
488 .background(.bar)
489 .transition(.move(edge: .bottom).combined(with: .opacity))
490 }
491 }
492
493 @ViewBuilder private var warningBanners: some View {
494 if appState.hasKeyChanged(for: peer.id, currentSafetyNumber: peer.safetyNumber) {
495 HStack(spacing: 6) {
496 Image(systemName: "exclamationmark.shield.fill").foregroundStyle(.red)
497 Text("\(peer.username)'s security key changed — verify identity before continuing.")
498 .font(.caption)
499 Spacer()
500 Button("Verify") { showingSafetyNumber = true }
501 .font(.caption.bold()).foregroundStyle(.red)
502 }
503 .padding(.horizontal, 14).padding(.vertical, 8)
504 .background(Color.red.opacity(0.08))
505 }
506 if appState.noOPKSessions.contains(peer.id) {
507 HStack(spacing: 6) {
508 Image(systemName: "key.slash").foregroundStyle(.yellow)
509 Text("Session established without one-time prekey — slightly reduced initial security.")
510 .font(.caption).foregroundStyle(.secondary)
511 Spacer()
512 }
513 .padding(.horizontal, 14).padding(.vertical, 6)
514 .background(Color.yellow.opacity(0.08))
515 }
516 if hasIncomingMessage,
517 !appState.isVerified(peer.id, currentSafetyNumber: peer.safetyNumber),
518 !verifyNudgeDismissed {
519 HStack(spacing: 6) {
520 Image(systemName: "person.badge.shield.checkmark").foregroundStyle(.yellow)
521 Text("Verify \(peer.username)'s identity to confirm you're talking to the right person.")
522 .font(.caption).foregroundStyle(.secondary)
523 Spacer()
524 Button("Verify") { showingSafetyNumber = true }
525 .font(.caption.bold()).foregroundStyle(.yellow)
526 Button { verifyNudgeDismissed = true } label: {
527 Image(systemName: "xmark").font(.caption2).foregroundStyle(.secondary)
528 }
529 }
530 .padding(.horizontal, 14).padding(.vertical, 8)
531 .background(Color.yellow.opacity(0.08))
532 }
533 if vm.disappearingInterval != .off {
534 HStack(spacing: 4) {
535 Image(systemName: "timer").font(.caption2)
536 Text("Messages disappear after \(vm.disappearingInterval.rawValue.lowercased())").font(.caption2)
537 }
538 .foregroundStyle(.orange)
539 .padding(.horizontal, 16).padding(.top, 6)
540 }
541 }
542
543 @ViewBuilder private var inputBar: some View {
544 @Bindable var vm = vm
545 HStack(spacing: 10) {
546 PhotosPicker(selection: $vm.photoPickerItem, matching: .any(of: [.images, .videos])) {
547 Image(systemName: "paperclip")
548 .font(.system(size: 22))
549 .foregroundStyle(.secondary)
550 }
551 .onChange(of: vm.photoPickerItem) { _, item in
552 guard let item else { return }
553 Task {
554 let expiresAt = vm.disappearingInterval.seconds.map { Date().addingTimeInterval($0) }
555 if item.supportedContentTypes.contains(where: { $0.conforms(to: .movie) || $0.identifier.contains("video") }) {
556 if let url = try? await item.loadTransferable(type: URL.self) {
557 await appState.sendVideo(url, toPeerID: peer.id, expiresAt: expiresAt)
558 }
559 } else if let data = try? await item.loadTransferable(type: Data.self),
560 let image = UIImage(data: data) {
561 appState.sendImage(image, toPeerID: peer.id, expiresAt: expiresAt)
562 }
563 vm.photoPickerItem = nil
564 }
565 }
566 Button { showingCamera = true } label: {
567 Image(systemName: "camera")
568 .font(.system(size: 22))
569 .foregroundStyle(.secondary)
570 }
571 Button { showingFilePicker = true } label: {
572 Image(systemName: "doc")
573 .font(.system(size: 20))
574 .foregroundStyle(.secondary)
575 }
576 TextField("Message", text: $vm.messageText, axis: .vertical)
577 .textFieldStyle(.plain)
578 .font(.body)
579 .lineLimit(1...6)
580 .focused($isInputFocused)
581 .autocorrectionDisabled()
582 .textInputAutocapitalization(.sentences)
583 .textContentType(.none)
584 .onChange(of: vm.messageText) { _, newValue in
585 let nonEmpty = !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
586 if nonEmpty {
587 appState.sendTypingIndicator(toPeerID: peer.id, isTyping: true)
588 vm.typingTask?.cancel()
589 vm.typingTask = Task { @MainActor in
590 try? await Task.sleep(for: .seconds(5))
591 appState.sendTypingIndicator(toPeerID: peer.id, isTyping: false)
592 vm.typingTask = nil
593 }
594 } else {
595 vm.typingTask?.cancel()
596 vm.typingTask = nil
597 appState.sendTypingIndicator(toPeerID: peer.id, isTyping: false)
598 }
599 }
600 sendOrMicButton
601 }
602 .animation(.easeInOut(duration: 0.15), value: canSend)
603 .padding(.horizontal, 16)
604 .padding(.vertical, 10)
605 .background(.bar)
606 .sheet(isPresented: $showingCamera) {
607 CameraPickerView { image in
608 guard let image else { return }
609 appState.sendImage(image, toPeerID: peer.id,
610 expiresAt: vm.disappearingInterval.seconds.map { Date().addingTimeInterval($0) })
611 }
612 }
613 .fileImporter(
614 isPresented: $showingFilePicker,
615 allowedContentTypes: [.item],
616 allowsMultipleSelection: false
617 ) { result in
618 if case .success(let urls) = result, let url = urls.first {
619 let expiresAt = vm.disappearingInterval.seconds.map { Date().addingTimeInterval($0) }
620 appState.sendFile(url, toPeerID: peer.id, expiresAt: expiresAt)
621 }
622 }
623 }
624
625 @ViewBuilder private var sendOrMicButton: some View {
626 if canSend {
627 Button(action: sendMessage) {
628 Image(systemName: "arrow.up.circle.fill")
629 .font(.system(size: 30))
630 .foregroundStyle(Color.accentColor)
631 }
632 .transition(.scale.combined(with: .opacity))
633 } else {
634 ZStack {
635 Circle()
636 .fill(voiceRecorder.isRecording ? Color.red.opacity(0.15) : Color.clear)
637 .frame(width: 36, height: 36)
638 .animation(.easeInOut(duration: 0.2), value: voiceRecorder.isRecording)
639 Image(systemName: voiceRecorder.isRecording ? "waveform" : "mic")
640 .font(.system(size: 20))
641 .foregroundStyle(voiceRecorder.isRecording ? .red : .secondary)
642 .symbolEffect(.pulse, isActive: voiceRecorder.isRecording)
643 }
644 .frame(width: 36, height: 36)
645 .contentShape(Rectangle())
646 .gesture(
647 DragGesture(minimumDistance: 0)
648 .onChanged { _ in
649 guard !voiceRecorder.isRecording else { return }
650 voiceRecorder.start()
651 }
652 .onEnded { _ in
653 voiceRecorder.stop { data, duration in
654 guard let data else { return }
655 appState.sendAudio(data, duration: duration, toPeerID: peer.id,
656 expiresAt: vm.disappearingInterval.seconds.map { Date().addingTimeInterval($0) })
657 }
658 }
659 )
660 }
661 }
662
663 /// Hide the timestamp row when the next message is from the same sender within 60 s.
664 private func showTimestamp(at index: Int, in msgs: [StoredMessage]) -> Bool {
665 guard index + 1 < msgs.count else { return true }
666 let curr = msgs[index]; let next = msgs[index + 1]
667 return curr.direction != next.direction
668 || next.timestamp.timeIntervalSince(curr.timestamp) > 60
669 }
670
671 private var canSend: Bool {
672 !vm.messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
673 }
674
675 private func sendMessage() {
676 let text = vm.messageText.trimmingCharacters(in: .whitespacesAndNewlines)
677 guard !text.isEmpty else { return }
678 UIImpactFeedbackGenerator(style: .light).impactOccurred()
679 vm.typingTask?.cancel()
680 vm.typingTask = nil
681 appState.sendTypingIndicator(toPeerID: peer.id, isTyping: false)
682 vm.messageText = ""
683 vm.clearDraft()
684 if let editing = vm.editingMessage {
685 withAnimation { vm.editingMessage = nil }
686 appState.sendEditMessage(messageID: editing.id, newBody: text, toPeerID: peer.id)
687 } else {
688 let reply = vm.replyingTo
689 withAnimation { vm.replyingTo = nil }
690 let expiresAt = vm.disappearingInterval.seconds.map { Date().addingTimeInterval($0) }
691 appState.sendMessage(text, toPeerID: peer.id, expiresAt: expiresAt, replyToID: reply?.id)
692 }
693 }
694}
695
696// MARK: - Privacy screen protection
697
698/// Blurs message content during screen recording and marks it sensitive
699/// for the iOS app-switcher snapshot. Screenshots cannot be blocked by
700/// iOS apps, but we log a security event so the user is aware.
701struct PrivacyScreenModifier: ViewModifier {
702 @State private var isBeingCaptured = false
703
704 func body(content: Content) -> some View {
705 content
706 .blur(radius: isBeingCaptured ? 24 : 0)
707 .animation(.easeInOut(duration: 0.2), value: isBeingCaptured)
708 .privacySensitive()
709 .onReceive(NotificationCenter.default.publisher(
710 for: UIScreen.capturedDidChangeNotification
711 )) { _ in
712 isBeingCaptured = UIScreen.main.isCaptured
713 }
714 .onReceive(NotificationCenter.default.publisher(
715 for: UIApplication.userDidTakeScreenshotNotification
716 )) { _ in
717 // Log security event — cannot prevent screenshot on iOS but
718 // callers can react (e.g. show a warning toast in future).
719 NotificationCenter.default.post(
720 name: .init("com.sophax.screenshotDetected"), object: nil
721 )
722 }
723 .onAppear {
724 isBeingCaptured = UIScreen.main.isCaptured
725 }
726 }
727}
728
729extension View {
730 func privacyScreen() -> some View {
731 modifier(PrivacyScreenModifier())
732 }
733}
734
735// MARK: - Scroll tracking
736
737struct BottomOffsetKey: PreferenceKey {
738 static var defaultValue: CGFloat = .greatestFiniteMagnitude
739 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
740}
741
742// MARK: - Date Separator
743
744struct DateSeparatorView: View {
745 let date: Date
746
747 private var label: String {
748 if Calendar.current.isDateInToday(date) { return "Today" }
749 if Calendar.current.isDateInYesterday(date) { return "Yesterday" }
750 return date.formatted(.dateTime.month(.wide).day().year())
751 }
752
753 var body: some View {
754 HStack(spacing: 8) {
755 Rectangle()
756 .fill(Color.secondary.opacity(0.2))
757 .frame(height: 0.5)
758 Text(label)
759 .font(.caption2.weight(.medium))
760 .foregroundStyle(.secondary)
761 .fixedSize()
762 Rectangle()
763 .fill(Color.secondary.opacity(0.2))
764 .frame(height: 0.5)
765 }
766 .padding(.horizontal, 24)
767 .padding(.vertical, 6)
768 }
769}
770
771// MARK: - Typing Bubble
772
773private struct TypingBubbleView: View {
774 @State private var phase = 0
775
776 var body: some View {
777 HStack(spacing: 4) {
778 ForEach(0..<3, id: \.self) { i in
779 Circle()
780 .fill(Color.secondary.opacity(phase == i ? 1.0 : 0.3))
781 .frame(width: 7, height: 7)
782 .offset(y: phase == i ? -3 : 0)
783 .animation(.easeInOut(duration: 0.35), value: phase)
784 }
785 }
786 .padding(.horizontal, 14)
787 .padding(.vertical, 12)
788 .background(Color(.systemGray5))
789 .clipShape(RoundedRectangle(cornerRadius: 18))
790 .frame(maxWidth: .infinity, alignment: .leading)
791 .task {
792 while !Task.isCancelled {
793 for i in 0..<3 {
794 phase = i
795 try? await Task.sleep(for: .milliseconds(350))
796 }
797 }
798 }
799 }
800}
801
802// MARK: - Safety Number View
803
804struct SafetyNumberView: View {
805 @Environment(\.dismiss) private var dismiss
806 @EnvironmentObject var appState: AppState
807 let peer: KnownPeer
808
809 @State private var showingMyQR = false
810 @State private var showingPeerQR = false
811 @State private var showingKeyHistory = false
812
813 private var mySafetyNumber: String? {
814 appState.chatManager?.identity.publicIdentity.safetyNumber
815 }
816
817 var body: some View {
818 NavigationStack {
819 ScrollView {
820 VStack(spacing: 28) {
821 Image(systemName: "checkmark.shield.fill")
822 .font(.system(size: 48))
823 .foregroundStyle(.green)
824 .padding(.top, 24)
825
826 Text("Compare both numbers out loud or in person. If they match on both devices, the connection is authentic.")
827 .font(.subheadline)
828 .foregroundStyle(.secondary)
829 .multilineTextAlignment(.center)
830 .padding(.horizontal)
831
832 // Their number
833 SafetyNumberBlock(
834 label: "\(peer.username)'s number",
835 sublabel: "They read this to you",
836 safetyNumber: peer.safetyNumber,
837 color: .blue,
838 onShowQR: { showingPeerQR = true }
839 )
840
841 // Your number
842 if let mine = mySafetyNumber {
843 SafetyNumberBlock(
844 label: "Your number",
845 sublabel: "You read this to them",
846 safetyNumber: mine,
847 color: .green,
848 onShowQR: { showingMyQR = true }
849 )
850 .sheet(isPresented: $showingMyQR) {
851 QRSheet(title: "Your Safety Number", safetyNumber: mine)
852 }
853 }
854
855 // Key History — collapsible section
856 KeyHistorySection(peerID: peer.id, isExpanded: $showingKeyHistory)
857
858 Text("If either number doesn't match, someone may be intercepting your messages. Do not continue.")
859 .font(.caption)
860 .foregroundStyle(.secondary)
861 .multilineTextAlignment(.center)
862 .padding(.horizontal)
863 .padding(.bottom, 24)
864 }
865 }
866 .navigationTitle("Verify Identity")
867 .navigationBarTitleDisplayMode(.inline)
868 .toolbar {
869 ToolbarItem(placement: .confirmationAction) {
870 Button("Done") { dismiss() }
871 }
872 }
873 .safeAreaInset(edge: .bottom) {
874 VStack(spacing: 10) {
875 if appState.isVerified(peer.id, currentSafetyNumber: peer.safetyNumber) {
876 Label("Identity Verified", systemImage: "checkmark.shield.fill")
877 .foregroundStyle(.green)
878 .font(.subheadline.bold())
879 } else {
880 Button {
881 appState.markPeerVerified(peer.id, safetyNumber: peer.safetyNumber)
882 dismiss()
883 } label: {
884 Label("Mark as Verified", systemImage: "checkmark.shield")
885 .frame(maxWidth: .infinity)
886 }
887 .buttonStyle(.borderedProminent)
888 .padding(.horizontal)
889 }
890 }
891 .padding(.vertical, 12)
892 .background(.bar)
893 }
894 .sheet(isPresented: $showingPeerQR) {
895 QRSheet(title: "\(peer.username)'s Safety Number", safetyNumber: peer.safetyNumber)
896 }
897 }
898 }
899}
900
901private struct QRSheet: View {
902 @Environment(\.dismiss) private var dismiss
903 let title: String
904 let safetyNumber: String
905
906 var body: some View {
907 NavigationStack {
908 VStack(spacing: 24) {
909 QRCodeView(safetyNumber: safetyNumber)
910 .padding(.top, 24)
911 Text("Scan this with the other device to compare safety numbers.")
912 .font(.subheadline)
913 .foregroundStyle(.secondary)
914 .multilineTextAlignment(.center)
915 .padding(.horizontal)
916 }
917 .navigationTitle(title)
918 .navigationBarTitleDisplayMode(.inline)
919 .toolbar {
920 ToolbarItem(placement: .confirmationAction) {
921 Button("Done") { dismiss() }
922 }
923 }
924 }
925 }
926}
927
928// MARK: - QR Code generator
929
930private struct QRCodeView: View {
931 let safetyNumber: String
932
933 private var qrImage: Image? {
934 let context = CIContext()
935 let filter = CIFilter.qrCodeGenerator()
936 filter.message = Data(safetyNumber.utf8)
937 filter.correctionLevel = "H"
938 guard let output = filter.outputImage else { return nil }
939 let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
940 guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
941 return Image(decorative: cgImage, scale: 1)
942 }
943
944 var body: some View {
945 if let img = qrImage {
946 img
947 .interpolation(.none)
948 .resizable()
949 .scaledToFit()
950 .frame(width: 200, height: 200)
951 .padding(12)
952 .background(.white)
953 .clipShape(RoundedRectangle(cornerRadius: 12))
954 }
955 }
956}
957
958// MARK: - Forward Picker
959
960struct ForwardPickerView: View {
961 @EnvironmentObject var appState: AppState
962 @Environment(\.dismiss) private var dismiss
963 let message: StoredMessage
964
965 var body: some View {
966 NavigationStack {
967 Group {
968 if appState.peers.isEmpty {
969 ContentUnavailableView(
970 "No Contacts",
971 systemImage: "person.slash",
972 description: Text("No nearby peers to forward to.")
973 )
974 } else {
975 List(appState.peers) { peer in
976 Button {
977 appState.forwardMessage(message, toPeerID: peer.id)
978 dismiss()
979 } label: {
980 HStack(spacing: 12) {
981 Circle()
982 .fill(Color.accentColor.opacity(0.15))
983 .frame(width: 36, height: 36)
984 .overlay {
985 Text(String(appState.displayName(for: peer).prefix(1)).uppercased())
986 .font(.subheadline.bold())
987 .foregroundStyle(Color.accentColor)
988 }
989 VStack(alignment: .leading, spacing: 2) {
990 Text(appState.displayName(for: peer))
991 .font(.subheadline.weight(.medium))
992 .foregroundStyle(.primary)
993 if appState.onlinePeers.contains(peer.id) {
994 Text("Online")
995 .font(.caption2)
996 .foregroundStyle(.green)
997 } else {
998 Text("Offline")
999 .font(.caption2)
1000 .foregroundStyle(.secondary)
1001 }
1002 }
1003 Spacer()
1004 Image(systemName: "arrowshape.turn.up.right")
1005 .foregroundStyle(.secondary)
1006 }
1007 }
1008 }
1009 }
1010 }
1011 .navigationTitle("Forward To")
1012 .navigationBarTitleDisplayMode(.inline)
1013 .toolbar {
1014 ToolbarItem(placement: .cancellationAction) {
1015 Button("Cancel") { dismiss() }
1016 }
1017 }
1018 }
1019 }
1020}
1021
1022// MARK: - Key History Section
1023
1024private struct KeyHistorySection: View {
1025 @EnvironmentObject var appState: AppState
1026 let peerID: String
1027 @Binding var isExpanded: Bool
1028
1029 private var history: [KeyLogEntry] { appState.keyHistory(for: peerID) }
1030
1031 var body: some View {
1032 VStack(spacing: 0) {
1033 Button {
1034 withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() }
1035 } label: {
1036 HStack {
1037 Label("Key History", systemImage: "key.horizontal")
1038 .font(.subheadline.weight(.semibold))
1039 Spacer()
1040 if history.count <= 1 {
1041 Image(systemName: "checkmark.shield.fill")
1042 .foregroundStyle(.green)
1043 .font(.caption)
1044 } else {
1045 Text("\(history.count) keys")
1046 .font(.caption)
1047 .foregroundStyle(.secondary)
1048 }
1049 Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
1050 .font(.caption2)
1051 .foregroundStyle(.secondary)
1052 }
1053 .padding(.horizontal, 16)
1054 .padding(.vertical, 12)
1055 .contentShape(Rectangle())
1056 }
1057 .buttonStyle(.plain)
1058
1059 if isExpanded {
1060 Divider().padding(.horizontal, 16)
1061 if history.isEmpty {
1062 HStack(spacing: 8) {
1063 Image(systemName: "clock.badge.questionmark")
1064 .foregroundStyle(.tertiary)
1065 Text("No history recorded yet.")
1066 .font(.caption)
1067 .foregroundStyle(.secondary)
1068 }
1069 .padding(.horizontal, 16)
1070 .padding(.vertical, 10)
1071 } else if history.count == 1 {
1072 HStack(spacing: 8) {
1073 Image(systemName: "checkmark.shield.fill")
1074 .foregroundStyle(.green)
1075 Text("Key has never changed.")
1076 .font(.caption)
1077 .foregroundStyle(.secondary)
1078 }
1079 .padding(.horizontal, 16)
1080 .padding(.vertical, 10)
1081 } else {
1082 VStack(spacing: 0) {
1083 ForEach(Array(history.enumerated().reversed()), id: \.offset) { idx, entry in
1084 let isCurrent = idx == history.indices.last
1085 HStack(spacing: 10) {
1086 VStack(alignment: .leading, spacing: 2) {
1087 HStack(spacing: 6) {
1088 Text(entry.signingKeyPublic.prefix(4)
1089 .map { String(format: "%02x", $0) }.joined())
1090 .font(.system(.caption, design: .monospaced).bold())
1091 Text(isCurrent ? "Current" : "Previous")
1092 .font(.caption2)
1093 .padding(.horizontal, 6)
1094 .padding(.vertical, 2)
1095 .background(isCurrent ? Color.green.opacity(0.12) : Color.secondary.opacity(0.1))
1096 .foregroundStyle(isCurrent ? .green : .secondary)
1097 .clipShape(Capsule())
1098 }
1099 Text(entry.firstSeen.formatted(date: .abbreviated, time: .shortened))
1100 .font(.caption2)
1101 .foregroundStyle(.tertiary)
1102 }
1103 Spacer()
1104 }
1105 .padding(.horizontal, 16)
1106 .padding(.vertical, 8)
1107 if idx != history.startIndex {
1108 Divider().padding(.leading, 16)
1109 }
1110 }
1111 }
1112 }
1113 }
1114 }
1115 .background(Color(.secondarySystemGroupedBackground))
1116 .clipShape(RoundedRectangle(cornerRadius: 12))
1117 .padding(.horizontal)
1118 }
1119}
1120
1121private struct SafetyNumberBlock: View {
1122 let label: String
1123 let sublabel: String
1124 let safetyNumber: String
1125 let color: Color
1126 let onShowQR: () -> Void
1127
1128 var body: some View {
1129 VStack(spacing: 10) {
1130 HStack {
1131 VStack(alignment: .leading, spacing: 2) {
1132 Text(label)
1133 .font(.subheadline.weight(.semibold))
1134 Text(sublabel)
1135 .font(.caption)
1136 .foregroundStyle(.secondary)
1137 }
1138 Spacer()
1139 Button(action: onShowQR) {
1140 Image(systemName: "qrcode")
1141 .foregroundStyle(color)
1142 }
1143 }
1144 .padding(.horizontal)
1145
1146 let groups = safetyNumber.split(separator: " ")
1147 LazyVGrid(columns: Array(repeating: .init(.flexible()), count: 3), spacing: 10) {
1148 ForEach(groups, id: \.self) { group in
1149 Text(group)
1150 .font(.system(.body, design: .monospaced).bold())
1151 .padding(10)
1152 .frame(maxWidth: .infinity)
1153 .background(color.opacity(0.08))
1154 .clipShape(RoundedRectangle(cornerRadius: 8))
1155 }
1156 }
1157 .padding(.horizontal)
1158 }
1159 }
1160}