1// GroupChatView.swift
2// SophaxChat
3//
4// Chat view for an encrypted group conversation.
5
6import SwiftUI
7import SophaxChatCore
8import PhotosUI
9import AVFoundation
10
11struct GroupChatView: View {
12 @EnvironmentObject var appState: AppState
13 @Environment(\.dismiss) private var dismiss
14 let group: GroupInfo
15
16 @State private var vm: GroupChatViewModel
17 @FocusState private var isInputFocused: Bool
18
19 // Pure UI flags (sheet/dialog visibility)
20 @State private var showingMemberList = false
21 @State private var showingLeaveConfirm = false
22 @State private var showingRotateConfirm = false
23 @State private var showingDeleteConfirm = false
24 @State private var showingDeleteLocallyConfirm = false
25 @State private var showingMigrationAlert = false
26 @State private var showingFilePicker = false
27 @State private var showAISheet = false
28
29 // PTT recording
30 @StateObject private var voiceRecorder = VoiceRecorder()
31
32 init(group: GroupInfo) {
33 self.group = group
34 _vm = State(initialValue: GroupChatViewModel(group: group))
35 }
36
37 private var messages: [StoredMessage] {
38 appState.messages[group.conversationID] ?? []
39 }
40
41 private var displayedMessages: [StoredMessage] {
42 guard vm.isSearching, !vm.searchQuery.isEmpty else { return messages }
43 return messages.filter { $0.body.localizedCaseInsensitiveContains(vm.searchQuery) }
44 }
45
46 private var memberCount: Int { group.memberIDs.count }
47
48 var body: some View {
49 @Bindable var vm = vm
50 mainContent
51 .navigationTitle(group.name)
52 .navigationBarTitleDisplayMode(.inline)
53 .toolbar { toolbarContent }
54 .sheet(isPresented: $showingMemberList) {
55 GroupMemberListView(group: group).environmentObject(appState)
56 }
57 .sheet(item: $vm.forwardingMessage) { message in
58 ForwardPickerView(message: message)
59 .environmentObject(appState)
60 }
61 .sheet(isPresented: $showAISheet) {
62 NavigationStack {
63 AIAssistantView(seedPrompt: vm.aiSeedPrompt)
64 }
65 }
66 .sheet(item: $vm.editingMessage) { message in
67 GroupEditMessageSheet(
68 message: message,
69 group: group,
70 editingText: $vm.editingText
71 )
72 .environmentObject(appState)
73 }
74 .confirmationDialog(
75 "Leave \"\(group.name)\"?",
76 isPresented: $showingLeaveConfirm,
77 titleVisibility: .visible
78 ) {
79 Button("Leave Group", role: .destructive) {
80 appState.leaveGroup(group); dismiss()
81 }
82 Button("Cancel", role: .cancel) {}
83 } message: {
84 Text("You will no longer receive messages from this group. This cannot be undone.")
85 }
86 .confirmationDialog(
87 "Delete \"\(group.name)\" from your device?",
88 isPresented: $showingDeleteLocallyConfirm,
89 titleVisibility: .visible
90 ) {
91 Button("Delete from My Device", role: .destructive) {
92 appState.deleteGroupLocally(group); dismiss()
93 }
94 Button("Cancel", role: .cancel) {}
95 } message: {
96 Text("Messages will be deleted from your device only. Other members will not be notified.")
97 }
98 .confirmationDialog(
99 "Delete \"\(group.name)\" for everyone?",
100 isPresented: $showingDeleteConfirm,
101 titleVisibility: .visible
102 ) {
103 Button("Delete Group", role: .destructive) {
104 appState.deleteGroup(group); dismiss()
105 }
106 Button("Cancel", role: .cancel) {}
107 } message: {
108 Text("All members will lose access to this group immediately. This cannot be undone.")
109 }
110 .alert("Upgrade to MLS", isPresented: $showingMigrationAlert) {
111 Button("OK") {}
112 } message: {
113 Text(vm.migrationAlertMessage)
114 }
115 }
116
117 @ViewBuilder private var pinnedMessageBanner: some View {
118 if let msgID = appState.pinnedMessages[group.conversationID],
119 let msg = messages.first(where: { $0.id == msgID }) {
120 HStack(spacing: 8) {
121 Image(systemName: "pin.fill")
122 .font(.caption2)
123 .foregroundStyle(.orange)
124 Text(msg.body.isEmpty ? "Attachment" : msg.body)
125 .font(.caption)
126 .lineLimit(1)
127 .foregroundStyle(.primary)
128 Spacer()
129 Button {
130 appState.unpinMessage(inConversation: group.conversationID)
131 } label: {
132 Image(systemName: "xmark")
133 .font(.caption2)
134 .foregroundStyle(.secondary)
135 }
136 }
137 .padding(.horizontal, 14)
138 .padding(.vertical, 6)
139 .background(Color.orange.opacity(0.08))
140 }
141 }
142
143 private var mainContent: some View {
144 VStack(spacing: 0) {
145 pinnedMessageBanner
146 messageScrollView
147 disappearingBanner
148 Divider()
149 if vm.isSearching {
150 searchBar
151 }
152 replyPreviewBar
153 inputBar
154 }
155 }
156
157 private var searchBar: some View {
158 @Bindable var vm = vm
159 return HStack(spacing: 8) {
160 Image(systemName: "magnifyingglass")
161 .foregroundStyle(.tertiary)
162 TextField("Search messages…", text: $vm.searchQuery)
163 .autocorrectionDisabled()
164 .textInputAutocapitalization(.never)
165 if !vm.searchQuery.isEmpty {
166 Button { vm.searchQuery = "" } label: {
167 Image(systemName: "xmark.circle.fill")
168 .foregroundStyle(.tertiary)
169 }
170 }
171 }
172 .padding(.horizontal, 16)
173 .padding(.vertical, 8)
174 .background(.bar)
175 }
176
177 private var messageScrollView: some View {
178 ScrollViewReader { proxy in
179 ScrollView {
180 LazyVStack(spacing: 8) {
181 ForEach(Array(displayedMessages.enumerated()), id: \.element.id) { index, message in
182 let prevDate = index > 0 ? displayedMessages[index - 1].timestamp : nil
183 if prevDate == nil || !Calendar.current.isDate(message.timestamp, inSameDayAs: prevDate!) {
184 DateSeparatorView(date: message.timestamp)
185 }
186 GroupMessageBubble(
187 message: message,
188 group: group,
189 replyingTo: messages.first { $0.id == message.replyToID },
190 onReply: { withAnimation { vm.replyingTo = message } },
191 onForward: { vm.forwardingMessage = message },
192 onEdit: {
193 vm.editingText = message.body
194 vm.editingMessage = message
195 },
196 onPin: {
197 let convID = group.conversationID
198 if appState.pinnedMessages[convID] == message.id {
199 appState.unpinMessage(inConversation: convID)
200 } else {
201 appState.pinMessage(message.id, inConversation: convID)
202 }
203 },
204 onAIAction: { prompt in
205 vm.aiSeedPrompt = prompt
206 showAISheet = true
207 }
208 )
209 }
210 Color.clear.frame(height: 1).id("bottom")
211 }
212 .padding(.horizontal, 16)
213 .padding(.top, 8)
214 .padding(.bottom, 12)
215 }
216 .onChange(of: messages.count) { _, _ in
217 withAnimation { proxy.scrollTo("bottom", anchor: .bottom) }
218 appState.markGroupAsRead(group: group)
219 }
220 .onAppear {
221 proxy.scrollTo("bottom", anchor: .bottom)
222 appState.markGroupAsRead(group: group)
223 }
224 .onDisappear {
225 vm.saveDraft()
226 }
227 .privacyScreen()
228 }
229 }
230
231 @ViewBuilder
232 private var disappearingBanner: some View {
233 if vm.disappearingInterval != .off {
234 HStack(spacing: 4) {
235 Image(systemName: "timer").font(.caption2)
236 Text("Messages disappear after \(vm.disappearingInterval.rawValue.lowercased())")
237 .font(.caption2)
238 }
239 .foregroundStyle(Color.orange)
240 .padding(.horizontal, 16)
241 .padding(.top, 6)
242 }
243 }
244
245 @ToolbarContentBuilder
246 private var toolbarContent: some ToolbarContent {
247 ToolbarItem(placement: .topBarLeading) {
248 Button {
249 withAnimation { vm.isSearching.toggle() }
250 if !vm.isSearching { vm.searchQuery = "" }
251 } label: {
252 Image(systemName: vm.isSearching ? "xmark.circle" : "magnifyingglass")
253 }
254 }
255 if #available(iOS 26.0, *) {
256 ToolbarItem(placement: .topBarTrailing) {
257 Button {
258 let msgs = messages.suffix(20).map { msg in
259 let name = msg.direction == .sent
260 ? "Me"
261 : (msg.senderID.flatMap { appState.displayName(forPeerID: $0) } ?? "Member")
262 return name + ": " + msg.body
263 }.joined(separator: "\n")
264 vm.aiSeedPrompt = "Summarize this group conversation in 3 concise bullet points:\n\n\(msgs)"
265 showAISheet = true
266 } label: {
267 Image(systemName: "sparkles")
268 }
269 .disabled(messages.isEmpty)
270 }
271 }
272 ToolbarItem(placement: .topBarTrailing) { timerMenu }
273 ToolbarItem(placement: .topBarTrailing) { groupMenu }
274 }
275
276 private var timerMenu: some View {
277 Menu {
278 ForEach(DisappearingInterval.allCases) { interval in
279 Button {
280 vm.disappearingInterval = interval
281 vm.saveDisappearing()
282 } label: {
283 if vm.disappearingInterval == interval {
284 Label(interval.rawValue, systemImage: "checkmark")
285 } else {
286 Text(interval.rawValue)
287 }
288 }
289 }
290 } label: {
291 Image(systemName: vm.disappearingInterval.icon)
292 .foregroundStyle(vm.disappearingInterval == .off ? Color.primary : Color.orange)
293 }
294 }
295
296 private var groupMenu: some View {
297 Menu {
298 Button { showingMemberList = true } label: {
299 Label("Members (\(group.memberIDs.count))", systemImage: "person.2")
300 }
301 Divider()
302 Button { showingRotateConfirm = true } label: {
303 Label("Reset Encryption Key", systemImage: "key.slash")
304 }
305 if group.cryptoVersion == .senderKeysV2,
306 group.creatorID == appState.chatManager?.identity.publicIdentity.peerID {
307 Divider()
308 Button {
309 appState.migrateGroupToMLS(group) { result in
310 switch result {
311 case .notNeeded:
312 break
313 case .initiated:
314 vm.migrationAlertMessage = "Migration started. A new MLS group has been created with the same members."
315 showingMigrationAlert = true
316 case .requiresAllOnline(let ids):
317 vm.migrationAlertMessage = "\(ids.count) member(s) are missing MLS keys. Ask them to open SophaxChat while nearby, then try again."
318 showingMigrationAlert = true
319 }
320 }
321 } label: {
322 Label("Upgrade to MLS", systemImage: "lock.shield")
323 }
324 }
325 Divider()
326 Button(role: .destructive) { showingLeaveConfirm = true } label: {
327 Label("Leave Group", systemImage: "rectangle.portrait.and.arrow.right")
328 }
329 if group.creatorID == appState.chatManager?.identity.publicIdentity.peerID {
330 Button(role: .destructive) { showingDeleteConfirm = true } label: {
331 Label("Delete Group", systemImage: "trash")
332 }
333 } else {
334 Button(role: .destructive) { showingDeleteLocallyConfirm = true } label: {
335 Label("Delete from My Device", systemImage: "trash")
336 }
337 }
338 } label: {
339 VStack(spacing: 0) {
340 Image(systemName: "person.2").font(.caption2)
341 Text("\(group.memberIDs.count)").font(.caption2).foregroundStyle(.secondary)
342 }
343 }
344 .confirmationDialog(
345 "Reset Encryption Key?",
346 isPresented: $showingRotateConfirm,
347 titleVisibility: .visible
348 ) {
349 Button("Reset Key", role: .destructive) {
350 appState.rotateSenderKey(forGroup: group)
351 }
352 Button("Cancel", role: .cancel) {}
353 } message: {
354 Text("A new encryption key will be generated and shared with all members. Use this if your device may have been compromised.")
355 }
356 }
357
358 // MARK: - Sub-views
359
360 @ViewBuilder
361 private var replyPreviewBar: some View {
362 if let replying = vm.replyingTo {
363 HStack(spacing: 10) {
364 Rectangle()
365 .fill(Color.accentColor)
366 .frame(width: 3)
367 .clipShape(Capsule())
368 VStack(alignment: .leading, spacing: 2) {
369 Text(replying.direction == .sent
370 ? "Reply to yourself"
371 : "Reply to \(appState.displayName(forPeerID: replying.senderID ?? ""))")
372 .font(.caption.bold())
373 .foregroundStyle(Color.accentColor)
374 Text(replying.body)
375 .font(.caption)
376 .foregroundStyle(.secondary)
377 .lineLimit(1)
378 }
379 Spacer()
380 Button { withAnimation { vm.replyingTo = nil } } label: {
381 Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary)
382 }
383 }
384 .padding(.horizontal, 16)
385 .padding(.vertical, 8)
386 .background(.bar)
387 .transition(.move(edge: .bottom).combined(with: .opacity))
388 }
389 }
390
391 @ViewBuilder
392 private var pttButton: some View {
393 ZStack {
394 Circle()
395 .fill(voiceRecorder.isRecording ? Color.red.opacity(0.15) : Color.clear)
396 .frame(width: 36, height: 36)
397 .animation(.easeInOut(duration: 0.2), value: voiceRecorder.isRecording)
398 Image(systemName: voiceRecorder.isRecording ? "waveform" : "mic")
399 .font(.system(size: 20))
400 .foregroundStyle(voiceRecorder.isRecording ? .red : .secondary)
401 .symbolEffect(.pulse, isActive: voiceRecorder.isRecording)
402 }
403 .frame(width: 36, height: 36)
404 .contentShape(Rectangle())
405 .gesture(
406 DragGesture(minimumDistance: 0)
407 .onChanged { _ in
408 guard !voiceRecorder.isRecording else { return }
409 voiceRecorder.start()
410 }
411 .onEnded { _ in
412 voiceRecorder.stop { data, duration in
413 guard let data, duration > 0.5 else { return }
414 let expiresAt = vm.disappearingInterval.seconds.map { Date().addingTimeInterval($0) }
415 appState.sendGroupAudio(data, duration: duration, group: group,
416 expiresAt: expiresAt, replyToID: vm.replyingTo?.id)
417 vm.replyingTo = nil
418 }
419 }
420 )
421 }
422
423 private var inputBar: some View {
424 @Bindable var vm = vm
425 let isTextNonEmpty = !vm.messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
426 return HStack(spacing: 10) {
427 PhotosPicker(selection: $vm.photoPickerItem, matching: .any(of: [.images, .videos])) {
428 Image(systemName: "photo")
429 .font(.system(size: 22))
430 .foregroundStyle(.secondary)
431 }
432 .onChange(of: vm.photoPickerItem) { _, item in
433 guard let item else { return }
434 Task {
435 let expiresAt = vm.disappearingInterval.seconds.map { Date().addingTimeInterval($0) }
436 if item.supportedContentTypes.contains(where: { $0.conforms(to: .movie) || $0.identifier.contains("video") }) {
437 if let url = try? await item.loadTransferable(type: URL.self) {
438 await appState.sendGroupVideo(url, group: group, expiresAt: expiresAt)
439 }
440 } else if let data = try? await item.loadTransferable(type: Data.self),
441 let image = UIImage(data: data) {
442 appState.sendGroupImage(image, group: group, expiresAt: expiresAt, replyToID: vm.replyingTo?.id)
443 vm.replyingTo = nil
444 }
445 vm.photoPickerItem = nil
446 }
447 }
448 Button { showingFilePicker = true } label: {
449 Image(systemName: "doc")
450 .font(.system(size: 20))
451 .foregroundStyle(.secondary)
452 }
453 pttButton
454 TextField("Message", text: $vm.messageText, axis: .vertical)
455 .textFieldStyle(.plain)
456 .font(.body)
457 .lineLimit(1...6)
458 .focused($isInputFocused)
459 .autocorrectionDisabled()
460 .textInputAutocapitalization(.sentences)
461 .textContentType(.none)
462 if isTextNonEmpty {
463 Button(action: sendMessage) {
464 Image(systemName: "arrow.up.circle.fill")
465 .font(.system(size: 30))
466 .foregroundStyle(Color.accentColor)
467 }
468 .transition(.scale.combined(with: .opacity))
469 }
470 }
471 .animation(.easeInOut(duration: 0.15), value: isTextNonEmpty)
472 .padding(.horizontal, 16)
473 .padding(.vertical, 10)
474 .background(.bar)
475 .fileImporter(
476 isPresented: $showingFilePicker,
477 allowedContentTypes: [.item],
478 allowsMultipleSelection: false
479 ) { result in
480 if case .success(let urls) = result, let url = urls.first {
481 let expiresAt = vm.disappearingInterval.seconds.map { Date().addingTimeInterval($0) }
482 appState.sendGroupFile(url, group: group, expiresAt: expiresAt)
483 }
484 }
485 }
486
487 private func sendMessage() {
488 let text = vm.messageText.trimmingCharacters(in: .whitespacesAndNewlines)
489 guard !text.isEmpty else { return }
490 UIImpactFeedbackGenerator(style: .light).impactOccurred()
491 vm.messageText = ""
492 vm.clearDraft()
493 let expiresAt = vm.disappearingInterval.seconds.map { Date().addingTimeInterval($0) }
494 appState.sendGroupMessage(text, group: group, expiresAt: expiresAt, replyToID: vm.replyingTo?.id)
495 vm.replyingTo = nil
496 }
497}
498
499// MARK: - Group Member List Sheet
500
501private struct GroupMemberListView: View {
502 @EnvironmentObject var appState: AppState
503 @Environment(\.dismiss) private var dismiss
504 let group: GroupInfo
505
506 @State private var selectedMemberForVerification: KnownPeer? = nil
507
508 private var myPeerID: String {
509 (appState.chatManager?.identity.publicIdentity.peerID) ?? ""
510 }
511
512 var body: some View {
513 NavigationStack {
514 List {
515 Section {
516 ForEach(group.memberIDs, id: \.self) { peerID in
517 HStack(spacing: 12) {
518 if let peer = appState.peers.first(where: { $0.id == peerID }) {
519 PeerAvatar(peer: peer, size: 36)
520 } else {
521 Circle()
522 .fill(Color.secondary.opacity(0.2))
523 .frame(width: 36, height: 36)
524 .overlay {
525 Image(systemName: "person")
526 .font(.system(size: 14))
527 .foregroundStyle(.secondary)
528 }
529 }
530
531 VStack(alignment: .leading, spacing: 1) {
532 HStack(spacing: 4) {
533 Text(appState.displayName(forPeerID: peerID))
534 .font(.subheadline.weight(.medium))
535 if peerID == group.creatorID {
536 Text("Creator")
537 .font(.caption2)
538 .foregroundStyle(.secondary)
539 .padding(.horizontal, 5)
540 .padding(.vertical, 1)
541 .background(Color.secondary.opacity(0.15))
542 .clipShape(Capsule())
543 }
544 if group.cryptoVersion == .mls && peerID == group.currentCoordinatorID && peerID != group.creatorID {
545 Text("Coordinator")
546 .font(.caption2)
547 .foregroundStyle(.white)
548 .padding(.horizontal, 5)
549 .padding(.vertical, 1)
550 .background(Color.purple.opacity(0.8))
551 .clipShape(Capsule())
552 }
553 if peerID == myPeerID {
554 Text("You")
555 .font(.caption2)
556 .foregroundStyle(Color.accentColor)
557 .padding(.horizontal, 5)
558 .padding(.vertical, 1)
559 .background(Color.accentColor.opacity(0.1))
560 .clipShape(Capsule())
561 }
562 }
563 if appState.onlinePeers.contains(peerID) {
564 Text("Online")
565 .font(.caption2)
566 .foregroundStyle(.green)
567 }
568 }
569
570 Spacer()
571
572 // Verification shield — tap to verify identity (non-self known peers only)
573 if peerID != myPeerID,
574 let peer = appState.peers.first(where: { $0.id == peerID }) {
575 let keyChanged = appState.hasKeyChanged(for: peerID, currentSafetyNumber: peer.safetyNumber)
576 let verified = appState.isVerified(peerID, currentSafetyNumber: peer.safetyNumber)
577 Button {
578 selectedMemberForVerification = peer
579 } label: {
580 Image(systemName: keyChanged ? "exclamationmark.shield.fill"
581 : verified ? "checkmark.shield.fill"
582 : "checkmark.shield")
583 .font(.system(size: 16))
584 .foregroundStyle(keyChanged ? .red : verified ? .green : Color.secondary)
585 }
586 .buttonStyle(.plain)
587 }
588
589 // Make Coordinator button — only visible to current coordinator,
590 // only for MLS groups, not for self or already-coordinator
591 if group.cryptoVersion == .mls
592 && appState.isCoordinator(of: group)
593 && peerID != myPeerID
594 && peerID != group.currentCoordinatorID {
595 Button("Make Coordinator") {
596 appState.handoffGroupCoordinator(group, to: peerID)
597 }
598 .font(.caption)
599 .buttonStyle(.bordered)
600 }
601 }
602 .padding(.vertical, 2)
603 }
604 } header: {
605 Text("\(group.memberIDs.count) Members")
606 }
607 }
608 .navigationTitle(group.name)
609 .navigationBarTitleDisplayMode(.inline)
610 .toolbar {
611 ToolbarItem(placement: .confirmationAction) {
612 Button("Done") { dismiss() }
613 }
614 }
615 .sheet(item: $selectedMemberForVerification) { peer in
616 SafetyNumberView(peer: peer)
617 .environmentObject(appState)
618 }
619 }
620 }
621}
622
623// MARK: - Group Message Bubble
624
625private struct GroupMessageBubble: View {
626 @EnvironmentObject var appState: AppState
627 let message: StoredMessage
628 let group: GroupInfo
629 let replyingTo: StoredMessage?
630 let onReply: () -> Void
631 let onForward: () -> Void
632 let onEdit: () -> Void
633 var onPin: (() -> Void)? = nil
634 var onAIAction: ((String) -> Void)? = nil
635
636 @State private var dragOffset: CGFloat = 0
637 @State private var replyTriggered: Bool = false
638 private static let replyThreshold: CGFloat = 60
639
640 private var isSent: Bool { message.direction == .sent }
641
642 private var senderName: String {
643 guard let senderID = message.senderID else { return "" }
644 return appState.displayName(forPeerID: senderID)
645 }
646
647 var body: some View {
648 HStack {
649 if isSent { Spacer(minLength: 60) }
650
651 VStack(alignment: isSent ? .trailing : .leading, spacing: 2) {
652 if !isSent && !senderName.isEmpty {
653 Text(senderName)
654 .font(.caption2.bold())
655 .foregroundStyle(Color.accentColor)
656 .padding(.horizontal, 4)
657 }
658
659 VStack(alignment: isSent ? .trailing : .leading, spacing: 4) {
660 if let quoted = replyingTo {
661 QuotedBubble(message: quoted, isSentContext: isSent)
662 }
663
664 groupBubbleContent
665 }
666 .contextMenu {
667 Button {
668 withAnimation { onReply() }
669 } label: {
670 Label("Reply", systemImage: "arrowshape.turn.up.left")
671 }
672 if let onPin {
673 Button(action: onPin) {
674 Label("Pin Message", systemImage: "pin")
675 }
676 }
677 Button {
678 UIPasteboard.general.string = message.body
679 MessageBubbleView.clipboardClearTask?.cancel()
680 let task = DispatchWorkItem { UIPasteboard.general.items = [] }
681 MessageBubbleView.clipboardClearTask = task
682 DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: task)
683 } label: {
684 Label("Copy", systemImage: "doc.on.doc")
685 }
686 if message.attachmentID == nil, let onAIAction {
687 Divider()
688 Menu {
689 Button {
690 onAIAction("Translate this message to English:\n\n\(message.body)")
691 } label: {
692 Label("Translate", systemImage: "character.book.closed")
693 }
694 Button {
695 onAIAction("Analyze this message for phishing, scams, or suspicious content. Be concise:\n\n\(message.body)")
696 } label: {
697 Label("Check for Threats", systemImage: "shield.lefthalf.filled")
698 }
699 } label: {
700 Label("Ask AI", systemImage: "sparkles")
701 }
702 }
703 if isSent && message.attachmentID == nil && message.timestamp.timeIntervalSinceNow > -300 {
704 Button {
705 onEdit()
706 } label: {
707 Label("Edit", systemImage: "pencil")
708 }
709 }
710 Button {
711 onForward()
712 } label: {
713 Label("Forward", systemImage: "arrowshape.turn.up.right")
714 }
715 Divider()
716 let myID = appState.chatManager?.identity.publicIdentity.peerID ?? ""
717 ForEach(["👍", "❤️", "😂", "😮", "😢", "👎"], id: \.self) { emoji in
718 Button {
719 appState.sendGroupReaction(
720 emoji: message.reactions?[myID] == emoji ? nil : emoji,
721 messageID: message.id,
722 group: group
723 )
724 } label: {
725 if message.reactions?[myID] == emoji {
726 Label(emoji, systemImage: "checkmark")
727 } else {
728 Text(emoji)
729 }
730 }
731 }
732 }
733
734 if let reactions = message.reactions, !reactions.isEmpty {
735 ReactionPillRow(reactions: reactions)
736 }
737
738 HStack(spacing: 4) {
739 Text(message.timestamp, style: .time)
740 .font(.caption2)
741 .foregroundStyle(.tertiary)
742 if message.isEdited {
743 Text("edited")
744 .font(.caption2)
745 .foregroundStyle(.tertiary)
746 }
747 if let exp = message.expiresAt, exp > Date() {
748 Image(systemName: "timer")
749 .font(.caption2)
750 .foregroundStyle(.orange)
751 Text(exp, style: .timer)
752 .font(.caption2.monospacedDigit())
753 .foregroundStyle(.orange)
754 }
755 if isSent {
756 let delivered = message.deliveredBy?.count ?? 0
757 let read = message.readBy?.count ?? 0
758 let total = max(group.memberIDs.count - 1, 1)
759 let allRead = read >= total
760 let anyDelivered = delivered > 0
761
762 Image(systemName: allRead
763 ? "checkmark.circle.fill"
764 : (anyDelivered ? "checkmark.circle" : "circle"))
765 .font(.caption2)
766 .foregroundStyle(
767 allRead ? Color.accentColor
768 : anyDelivered ? Color(.tertiaryLabel)
769 : Color(.quaternaryLabel))
770 if allRead {
771 Text("Read \(read)/\(total)")
772 .font(.caption2)
773 .foregroundStyle(Color.accentColor)
774 } else {
775 Text("\(delivered)/\(total)")
776 .font(.caption2)
777 .foregroundStyle(anyDelivered ? Color(.tertiaryLabel) : Color(.quaternaryLabel))
778 }
779 }
780 }
781 }
782
783 if !isSent { Spacer(minLength: 60) }
784 }
785 .offset(x: dragOffset)
786 .background(alignment: .leading) {
787 if dragOffset > 0 {
788 Image(systemName: "arrowshape.turn.up.left.fill")
789 .font(.system(size: 20))
790 .foregroundStyle(Color.accentColor)
791 .opacity(min(1.0, Double(dragOffset) / Double(Self.replyThreshold) * 1.5))
792 .scaleEffect(min(1.0, dragOffset / Self.replyThreshold))
793 .padding(.leading, 8)
794 }
795 }
796 .gesture(
797 DragGesture(minimumDistance: 20)
798 .onChanged { value in
799 guard value.translation.width > 0,
800 abs(value.translation.width) > abs(value.translation.height) else { return }
801 dragOffset = min(value.translation.width, 80)
802 if dragOffset >= Self.replyThreshold && !replyTriggered {
803 replyTriggered = true
804 UIImpactFeedbackGenerator(style: .medium).impactOccurred()
805 onReply()
806 }
807 }
808 .onEnded { _ in
809 withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) {
810 dragOffset = 0
811 }
812 replyTriggered = false
813 }
814 )
815 }
816
817 @State private var showFileShare = false
818
819 @ViewBuilder
820 private var groupBubbleContent: some View {
821 let mime = message.attachmentMimeType ?? ""
822 if let id = message.attachmentID, mime.hasPrefix("image/"),
823 let data = appState.loadAttachment(id: id),
824 let uiImage = UIImage(data: data) {
825 Image(uiImage: uiImage)
826 .resizable()
827 .scaledToFill()
828 .frame(maxWidth: 220, maxHeight: 180)
829 .clipShape(RoundedRectangle(cornerRadius: 14))
830 if !message.body.isEmpty {
831 Text(message.body)
832 .font(.subheadline)
833 .foregroundStyle(isSent ? .white : .primary)
834 .padding(.horizontal, 14).padding(.vertical, 8)
835 .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
836 .clipShape(RoundedRectangle(cornerRadius: 14))
837 }
838 } else if mime.hasPrefix("audio/") {
839 HStack(spacing: 6) {
840 Image(systemName: "waveform").font(.body)
841 if let dur = message.audioDuration { Text(formatDuration(dur)).font(.subheadline) }
842 }
843 .foregroundStyle(isSent ? .white : .primary)
844 .padding(.horizontal, 14).padding(.vertical, 10)
845 .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
846 .clipShape(RoundedRectangle(cornerRadius: 18))
847 } else if message.attachmentID != nil && !mime.isEmpty
848 && !mime.hasPrefix("image/") && !mime.hasPrefix("audio/") && !mime.hasPrefix("video/") {
849 Button { showFileShare = true } label: {
850 HStack(spacing: 10) {
851 Image(systemName: "doc.fill")
852 .font(.system(size: 28))
853 .foregroundStyle(isSent ? .white : Color.accentColor)
854 VStack(alignment: .leading, spacing: 2) {
855 Text(message.attachmentFilename ?? "File")
856 .font(.body.weight(.medium))
857 .foregroundStyle(isSent ? .white : .primary)
858 .lineLimit(2)
859 Text(mime)
860 .font(.caption2)
861 .foregroundStyle(isSent ? .white.opacity(0.7) : .secondary)
862 }
863 }
864 .padding(.horizontal, 14).padding(.vertical, 12)
865 .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
866 .clipShape(RoundedRectangle(cornerRadius: 18))
867 }
868 .buttonStyle(.plain)
869 .sheet(isPresented: $showFileShare) {
870 if let id = message.attachmentID,
871 let data = appState.loadAttachment(id: id) {
872 FileShareSheet(data: data, filename: message.attachmentFilename ?? "file")
873 }
874 }
875 } else {
876 Text(message.body)
877 .font(.body)
878 .foregroundStyle(isSent ? .white : .primary)
879 .padding(.horizontal, 14).padding(.vertical, 10)
880 .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
881 .clipShape(RoundedRectangle(cornerRadius: 18))
882 }
883 }
884
885 private func formatDuration(_ seconds: Double) -> String {
886 let s = Int(seconds)
887 return String(format: "%d:%02d", s / 60, s % 60)
888 }
889}
890
891// MARK: - Quoted bubble (reply preview)
892
893private struct QuotedBubble: View {
894 let message: StoredMessage
895 let isSentContext: Bool
896
897 var body: some View {
898 HStack(spacing: 6) {
899 Rectangle()
900 .fill(Color.accentColor)
901 .frame(width: 2)
902 .clipShape(Capsule())
903 Text(message.body.isEmpty ? "Attachment" : message.body)
904 .font(.caption)
905 .foregroundStyle(.secondary)
906 .lineLimit(2)
907 }
908 .padding(.horizontal, 10)
909 .padding(.vertical, 6)
910 .background(Color(.tertiarySystemGroupedBackground))
911 .clipShape(RoundedRectangle(cornerRadius: 10))
912 }
913}
914
915// MARK: - Reaction pill row (reused from 1:1)
916
917private struct ReactionPillRow: View {
918 let reactions: [String: String]
919
920 private var counts: [(emoji: String, count: Int)] {
921 var tally: [String: Int] = [:]
922 for emoji in reactions.values { tally[emoji, default: 0] += 1 }
923 return tally.map { ($0.key, $0.value) }.sorted { $0.count > $1.count }
924 }
925
926 var body: some View {
927 HStack(spacing: 4) {
928 ForEach(counts, id: \.emoji) { item in
929 HStack(spacing: 2) {
930 Text(item.emoji).font(.caption)
931 if item.count > 1 {
932 Text("\(item.count)").font(.caption2).foregroundStyle(.secondary)
933 }
934 }
935 .padding(.horizontal, 7)
936 .padding(.vertical, 3)
937 .background(Color(.tertiarySystemGroupedBackground))
938 .clipShape(Capsule())
939 }
940 }
941 }
942}
943
944// MARK: - Group Edit Message Sheet
945
946private struct GroupEditMessageSheet: View {
947 @EnvironmentObject var appState: AppState
948 @Environment(\.dismiss) private var dismiss
949 let message: StoredMessage
950 let group: GroupInfo
951 @Binding var editingText: String
952
953 var body: some View {
954 NavigationStack {
955 VStack(spacing: 0) {
956 TextEditor(text: $editingText)
957 .padding(12)
958 .frame(minHeight: 120, maxHeight: 300)
959 .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(.systemGray4)))
960 .padding()
961 Spacer()
962 }
963 .navigationTitle("Edit Message")
964 .navigationBarTitleDisplayMode(.inline)
965 .toolbar {
966 ToolbarItem(placement: .cancellationAction) {
967 Button("Cancel") { dismiss() }
968 }
969 ToolbarItem(placement: .confirmationAction) {
970 Button("Save") {
971 let trimmed = editingText.trimmingCharacters(in: .whitespacesAndNewlines)
972 guard !trimmed.isEmpty else { return }
973 appState.sendGroupEditMessage(
974 messageID: message.id,
975 newBody: trimmed,
976 group: group
977 )
978 dismiss()
979 }
980 .disabled(editingText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
981 }
982 }
983 }
984 }
985}
986
987#Preview {
988 NavigationStack {
989 GroupChatView(group: GroupInfo(
990 name: "Test Group",
991 memberIDs: ["alice", "bob"],
992 creatorID: "alice"
993 ))
994 .environmentObject(AppState())
995 }
996}