1// ChatListView.swift
2// SophaxChat
3//
4// Main screen: list of conversations + nearby peers.
5
6import SwiftUI
7import SophaxChatCore
8
9struct ChatListView: View {
10 @EnvironmentObject var appState: AppState
11 @Environment(\.horizontalSizeClass) private var hSizeClass
12 @AppStorage("com.sophax.gettingStartedDismissed") private var gettingStartedDismissed = false
13 @State private var showingIdentity = false
14 @State private var showingSettings = false
15 @State private var showingCreateGroup = false
16 @State private var showingScanner = false
17 @State private var peerToBlock: KnownPeer? = nil
18 @State private var groupToLeave: GroupInfo? = nil
19 @State private var groupToDelete: GroupInfo? = nil
20 @State private var groupToDeleteLocally: GroupInfo? = nil
21 @State private var reconnectBannerPeer: KnownPeer? = nil
22 @State private var keyChangePeerID: String? = nil
23 @State private var showingAddByLink = false
24 @State private var pastedInviteLink = ""
25 @State private var inviteLinkError: String? = nil
26 @State private var showingFindByPeerID = false
27 @State private var dhtFoundPeer: KnownPeer? = nil
28
29 private var showGettingStartedCard: Bool {
30 appState.peers.filter({ !appState.isBlocked($0.id) }).isEmpty
31 && appState.groups.isEmpty
32 && !gettingStartedDismissed
33 }
34
35 var body: some View {
36 Group {
37 if hSizeClass == .regular {
38 splitBody
39 } else {
40 stackBody
41 }
42 }
43
44 // MARK: - iPad split view
45
46 private var splitBody: some View {
47 NavigationSplitView {
48 conversationList
49 .navigationDestination(for: KnownPeer.self) { peer in
50 ChatView(peer: peer)
51 }
52 .navigationDestination(for: GroupInfo.self) { group in
53 GroupChatView(group: group)
54 }
55 .navigationDestination(for: AIDestination.self) { _ in AIAssistantView() }
56 .navigationDestination(for: NoteToSelfDestination.self) { _ in NoteToSelfView() }
57 } detail: {
58 emptyDetailView
59 }
60 }
61
62 private var emptyDetailView: some View {
63 VStack(spacing: 16) {
64 Image(systemName: "bubble.left.and.bubble.right")
65 .font(.system(size: 52))
66 .foregroundStyle(.tertiary)
67 Text("Select a conversation")
68 .font(.title3)
69 .foregroundStyle(.secondary)
70 }
71 }
72
73 // MARK: - iPhone stack
74
75 private var stackBody: some View {
76 NavigationStack {
77 conversationList
78 .navigationDestination(for: KnownPeer.self) { peer in
79 ChatView(peer: peer)
80 }
81 .navigationDestination(for: GroupInfo.self) { group in
82 GroupChatView(group: group)
83 }
84 .navigationDestination(for: AIDestination.self) { _ in AIAssistantView() }
85 .navigationDestination(for: NoteToSelfDestination.self) { _ in NoteToSelfView() }
86 }
87 }
88
89 // MARK: - Shared list content
90
91 private var conversationList: some View {
92 List {
93 // Local AI assistant — always at the top
94 NavigationLink(value: AIDestination.assistant) {
95 HStack(spacing: 12) {
96 ZStack {
97 Circle()
98 .fill(Color.purple.opacity(0.12))
99 .frame(width: 48, height: 48)
100 Image(systemName: "sparkles")
101 .font(.system(size: 20))
102 .foregroundStyle(.purple)
103 }
104 VStack(alignment: .leading, spacing: 2) {
105 Text("Assistant")
106 .font(.subheadline.weight(.semibold))
107 Text("Local · Private · On-device")
108 .font(.caption2)
109 .foregroundStyle(.secondary)
110 }
111 Spacer()
112 }
113 .padding(.vertical, 4)
114 }
115
116 NavigationLink(value: NoteToSelfDestination.noteToSelf) {
117 HStack(spacing: 12) {
118 ZStack {
119 Circle()
120 .fill(Color.yellow.opacity(0.12))
121 .frame(width: 48, height: 48)
122 Image(systemName: "lock.doc.fill")
123 .font(.system(size: 20))
124 .foregroundStyle(.yellow)
125 }
126 VStack(alignment: .leading, spacing: 2) {
127 Text("Note to Self")
128 .font(.subheadline.weight(.semibold))
129 Text("Encrypted — local only")
130 .font(.caption2)
131 .foregroundStyle(.secondary)
132 }
133 Spacer()
134 }
135 .padding(.vertical, 4)
136 }
137
138 // Pending contact requests
139 if !appState.pendingContactRequests.isEmpty {
140 Section("Contact Requests") {
141 ForEach(appState.pendingContactRequests) { peer in
142 ContactRequestRow(peer: peer,
143 onAccept: { appState.acceptContact(peer) },
144 onReject: { appState.rejectContact(peer) })
145 }
146 }
147 }
148
149 // Active conversations (peers with messages, not blocked)
150 let conversationPeers = appState.peers.filter {
151 appState.messages[$0.id] != nil && !appState.isBlocked($0.id)
152 }
153 if !conversationPeers.isEmpty {
154 Section("Conversations") {
155 ForEach(conversationPeers) { peer in
156 NavigationLink(value: peer) {
157 ConversationRow(
158 peer: peer,
159 messages: appState.messages[peer.id] ?? [],
160 unreadCount: appState.unreadCounts[peer.id] ?? 0
161 )
162 }
163 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
164 Button(role: .destructive) {
165 appState.deleteConversation(peerID: peer.id)
166 } label: {
167 Label("Delete", systemImage: "trash")
168 }
169 Button {
170 peerToBlock = peer
171 } label: {
172 Label("Block", systemImage: "nosign")
173 }
174 .tint(.orange)
175 }
176 }
177 }
178 }
179
180 if !appState.groups.isEmpty {
181 Section("Groups") {
182 ForEach(appState.groups) { group in
183 NavigationLink(value: group) {
184 GroupConversationRow(group: group)
185 }
186 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
187 Button(role: .destructive) {
188 groupToLeave = group
189 } label: {
190 Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
191 }
192 if group.creatorID == appState.chatManager?.identity.publicIdentity.peerID {
193 Button(role: .destructive) {
194 groupToDelete = group
195 } label: {
196 Label("Delete", systemImage: "trash")
197 }
198 } else {
199 Button(role: .destructive) {
200 groupToDeleteLocally = group
201 } label: {
202 Label("Delete", systemImage: "trash")
203 }
204 }
205 }
206 }
207 }
208 }
209
210 // Pending join requests — someone wants to join a group we created
211 if !appState.pendingGroupJoinRequests.isEmpty {
212 Section {
213 ForEach(appState.pendingGroupJoinRequests, id: \.requesterPeerID) { req in
214 GroupJoinRequestRow(request: req)
215 }
216 } header: {
217 Text("Join Requests")
218 } footer: {
219 Text("Peers who want to join your group. Invite them or dismiss.")
220 .font(.caption2)
221 }
222 }
223
224 // Nearby channels — groups announced by peers the local user hasn't joined
225 let nearbyChannels = Array(appState.discoveredChannels.values)
226 .sorted { $0.groupName < $1.groupName }
227 if !nearbyChannels.isEmpty {
228 Section {
229 ForEach(nearbyChannels, id: \.groupID) { channel in
230 NearbyChannelRow(channel: channel)
231 }
232 } header: {
233 Text("Nearby Channels")
234 } footer: {
235 Text("Groups advertised by nearby peers. Tap a channel to request an invite.")
236 .font(.caption2)
237 }
238 }
239
240 // Online peers without conversations yet
241 let newPeers = appState.peers.filter {
242 appState.messages[$0.id] == nil
243 && appState.onlinePeers.contains($0.id)
244 && !appState.isBlocked($0.id)
245 }
246 if !newPeers.isEmpty {
247 Section("Nearby") {
248 ForEach(newPeers) { peer in
249 NavigationLink(value: peer) {
250 PeerRow(peer: peer, isOnline: true)
251 }
252 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
253 Button {
254 peerToBlock = peer
255 } label: {
256 Label("Block", systemImage: "nosign")
257 }
258 .tint(.orange)
259 }
260 }
261 }
262 }
263
264 if showGettingStartedCard {
265 Section {
266 GettingStartedCard(dismiss: { gettingStartedDismissed = true })
267 .listRowBackground(Color.clear)
268 .listRowInsets(EdgeInsets())
269 }
270 } else if appState.peers.filter({ !appState.isBlocked($0.id) }).isEmpty {
271 Section {
272 VStack(spacing: 12) {
273 Image(systemName: "bubble.left.and.bubble.right")
274 .font(.system(size: 40))
275 .foregroundStyle(.tertiary)
276 Text("No conversations yet")
277 .font(.subheadline.weight(.medium))
278 Button {
279 showingIdentity = true
280 } label: {
281 Label("Share My Contact", systemImage: "square.and.arrow.up")
282 }
283 .buttonStyle(.bordered)
284 .tint(.accentColor)
285 }
286 .frame(maxWidth: .infinity)
287 .padding(.vertical, 32)
288 .listRowBackground(Color.clear)
289 }
290 }
291 }
292 .navigationTitle("SophaxChat")
293 .toolbar {
294 ToolbarItem(placement: .navigationBarLeading) {
295 HStack(spacing: 4) {
296 Button {
297 showingScanner = true
298 } label: {
299 Image(systemName: "qrcode.viewfinder")
300 }
301 Button {
302 showingAddByLink = true
303 pastedInviteLink = ""
304 inviteLinkError = nil
305 } label: {
306 Image(systemName: "link.badge.plus")
307 }
308 Button {
309 showingFindByPeerID = true
310 } label: {
311 Image(systemName: "person.badge.magnifyingglass")
312 }
313 }
314 }
315 ToolbarItem(placement: .topBarTrailing) {
316 HStack(spacing: 4) {
317 Button {
318 showingCreateGroup = true
319 } label: {
320 Image(systemName: "person.2.badge.plus")
321 }
322 Button {
323 showingSettings = true
324 } label: {
325 Image(systemName: "gearshape")
326 }
327 Button {
328 showingIdentity = true
329 } label: {
330 Image(systemName: "person.crop.circle")
331 }
332 }
333 }
334 }
335 .onChange(of: appState.reconnectedPeer?.id) { _, peerID in
336 reconnectBannerPeer = appState.reconnectedPeer
337 }
338 .overlay(alignment: .top) {
339 if let peer = reconnectBannerPeer {
340 HStack(spacing: 8) {
341 Circle().fill(.green).frame(width: 8, height: 8)
342 Text("\(appState.displayName(for: peer)) is back online")
343 .font(.subheadline)
344 Spacer()
345 }
346 .padding(.horizontal, 16)
347 .padding(.vertical, 10)
348 .background(.thinMaterial)
349 .clipShape(RoundedRectangle(cornerRadius: 12))
350 .padding(.horizontal, 12)
351 .padding(.top, 8)
352 .transition(.move(edge: .top).combined(with: .opacity))
353 .onTapGesture { withAnimation { reconnectBannerPeer = nil } }
354 .task {
355 try? await Task.sleep(for: .seconds(3))
356 withAnimation { reconnectBannerPeer = nil }
357 }
358 }
359 }
360 .animation(.easeInOut(duration: 0.3), value: reconnectBannerPeer?.id)
361 .onChange(of: appState.keyChangeAlerts) { _, alerts in
362 if let peerID = alerts.last {
363 keyChangePeerID = peerID
364 }
365 }
366 .alert("Security Key Changed", isPresented: Binding(
367 get: { keyChangePeerID != nil },
368 set: { if !$0 { keyChangePeerID = nil } }
369 ), presenting: keyChangePeerID) { peerID in
370 Button("Verify Now") {
371 keyChangePeerID = nil
372 }
373 Button("Dismiss", role: .cancel) {
374 keyChangePeerID = nil
375 appState.keyChangeAlerts.removeAll { $0 == peerID }
376 }
377 } message: { peerID in
378 let name = appState.peers.first(where: { $0.id == peerID }).map { appState.displayName(for: $0) } ?? peerID
379 Text("\(name)'s identity key has changed. Open the conversation and verify their Safety Number to confirm this is expected.")
380 }
381 .sheet(isPresented: $showingIdentity) {
382 IdentityView()
383 }
384 .sheet(isPresented: $showingSettings) {
385 SettingsView()
386 }
387 .onReceive(NotificationCenter.default.publisher(for: .sophaxShowSettings)) { _ in
388 showingSettings = true
389 }
390 .sheet(isPresented: $showingCreateGroup) {
391 CreateGroupView()
392 }
393 .sheet(isPresented: $showingScanner) {
394 ContactScannerView().environmentObject(appState)
395 }
396 .sheet(isPresented: $showingAddByLink) {
397 addByLinkSheet
398 }
399 .sheet(isPresented: $showingFindByPeerID) {
400 FindByPeerIDView()
401 }
402 .onReceive(NotificationCenter.default.publisher(for: .sophaxOpenChat)) { note in
403 if let peer = note.object as? KnownPeer {
404 dhtFoundPeer = peer
405 }
406 }
407 .navigationDestination(item: $dhtFoundPeer) { peer in
408 ChatView(peer: peer)
409 }
410 .alert("Error", isPresented: Binding(
411 get: { appState.errorMessage != nil },
412 set: { if !$0 { appState.errorMessage = nil } }
413 ), presenting: appState.errorMessage) { _ in
414 Button("OK") { appState.errorMessage = nil }
415 } message: { msg in
416 Text(msg)
417 }
418 .confirmationDialog(
419 "Block \(peerToBlock?.username ?? "")?",
420 isPresented: Binding(get: { peerToBlock != nil }, set: { if !$0 { peerToBlock = nil } }),
421 titleVisibility: .visible
422 ) {
423 Button("Block", role: .destructive) {
424 if let peer = peerToBlock {
425 appState.blockPeer(peerID: peer.id)
426 }
427 peerToBlock = nil
428 }
429 Button("Cancel", role: .cancel) { peerToBlock = nil }
430 } message: {
431 Text("You won't receive messages from this person. This can be undone in Settings.")
432 }
433 .confirmationDialog(
434 "Leave \"\(groupToLeave?.name ?? "")\"?",
435 isPresented: Binding(get: { groupToLeave != nil }, set: { if !$0 { groupToLeave = nil } }),
436 titleVisibility: .visible
437 ) {
438 Button("Leave Group", role: .destructive) {
439 if let g = groupToLeave { appState.leaveGroup(g) }
440 groupToLeave = nil
441 }
442 Button("Cancel", role: .cancel) { groupToLeave = nil }
443 } message: {
444 Text("You will no longer receive messages from this group. This cannot be undone.")
445 }
446 .confirmationDialog(
447 "Delete \"\(groupToDelete?.name ?? "")\" for everyone?",
448 isPresented: Binding(get: { groupToDelete != nil }, set: { if !$0 { groupToDelete = nil } }),
449 titleVisibility: .visible
450 ) {
451 Button("Delete Group", role: .destructive) {
452 if let g = groupToDelete { appState.deleteGroup(g) }
453 groupToDelete = nil
454 }
455 Button("Cancel", role: .cancel) { groupToDelete = nil }
456 } message: {
457 Text("All members will lose access to this group immediately. This cannot be undone.")
458 }
459 .confirmationDialog(
460 "Delete \"\(groupToDeleteLocally?.name ?? "")\"?",
461 isPresented: Binding(get: { groupToDeleteLocally != nil }, set: { if !$0 { groupToDeleteLocally = nil } }),
462 titleVisibility: .visible
463 ) {
464 Button("Delete from My Device", role: .destructive) {
465 if let g = groupToDeleteLocally { appState.deleteGroupLocally(g) }
466 groupToDeleteLocally = nil
467 }
468 Button("Cancel", role: .cancel) { groupToDeleteLocally = nil }
469 } message: {
470 Text("Messages will be deleted from your device only. Other members will not be notified.")
471 }
472 }
473
474 // MARK: - Add by Link sheet
475
476 @ViewBuilder private var addByLinkSheet: some View {
477 NavigationStack {
478 Form {
479 Section {
480 TextField("sophaxchat://…", text: $pastedInviteLink, axis: .vertical)
481 .lineLimit(3...6)
482 .autocorrectionDisabled()
483 .textInputAutocapitalization(.never)
484 .keyboardType(.URL)
485 } header: {
486 Text("Paste invite link")
487 } footer: {
488 Text("Paste a sophaxchat:// link shared by another user via any channel (iMessage, email, etc.).")
489 }
490
491 if let err = inviteLinkError {
492 Section { Text(err).foregroundStyle(.red) }
493 }
494
495 Section {
496 Button("Add Contact") {
497 processLink()
498 }
499 .disabled(pastedInviteLink.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
500 }
501 }
502 .navigationTitle("Add by Link")
503 .navigationBarTitleDisplayMode(.inline)
504 .toolbar {
505 ToolbarItem(placement: .cancellationAction) {
506 Button("Cancel") { showingAddByLink = false }
507 }
508 }
509 }
510 .presentationDetents([.medium])
511 }
512
513 private func processLink() {
514 let raw = pastedInviteLink.trimmingCharacters(in: .whitespacesAndNewlines)
515 guard let url = URL(string: raw), url.scheme == "sophaxchat" else {
516 inviteLinkError = "Not a valid sophaxchat:// link."
517 return
518 }
519 inviteLinkError = nil
520 showingAddByLink = false
521 appState.handleIncomingLink(url)
522 }
523}
524
525// MARK: - Group Conversation Row
526
527struct GroupConversationRow: View {
528 @EnvironmentObject var appState: AppState
529 let group: GroupInfo
530
531 private var lastMessage: StoredMessage? {
532 appState.messages[group.conversationID]?.last
533 }
534 private var unreadCount: Int {
535 appState.unreadCounts[group.conversationID] ?? 0
536 }
537
538 var body: some View {
539 HStack(spacing: 12) {
540 ZStack {
541 Circle()
542 .fill(Color.accentColor.opacity(0.15))
543 .frame(width: 48, height: 48)
544 Image(systemName: "person.2.fill")
545 .font(.system(size: 20))
546 .foregroundStyle(Color.accentColor)
547 }
548
549 VStack(alignment: .leading, spacing: 2) {
550 HStack {
551 Text(group.name)
552 .font(.subheadline.weight(unreadCount > 0 ? .bold : .semibold))
553 if group.cryptoVersion == .mls {
554 Text("MLS")
555 .font(.caption2.weight(.semibold))
556 .foregroundStyle(.white)
557 .padding(.horizontal, 5)
558 .padding(.vertical, 2)
559 .background(Color.purple.opacity(0.85))
560 .clipShape(Capsule())
561 }
562 Spacer()
563 if let last = lastMessage {
564 Text(last.timestamp, style: .relative)
565 .font(.caption2)
566 .foregroundStyle(.tertiary)
567 }
568 }
569 HStack {
570 Text("\(group.memberIDs.count) members")
571 .font(.caption2)
572 .foregroundStyle(.tertiary)
573 if let last = lastMessage {
574 Text("· \(last.body)")
575 .font(.subheadline)
576 .foregroundStyle(unreadCount > 0 ? .primary : .secondary)
577 .fontWeight(unreadCount > 0 ? .medium : .regular)
578 .lineLimit(1)
579 }
580 Spacer()
581 if unreadCount > 0 {
582 Text("\(unreadCount)")
583 .font(.caption2.bold())
584 .foregroundStyle(.white)
585 .padding(.horizontal, 6)
586 .padding(.vertical, 2)
587 .background(Color.accentColor)
588 .clipShape(Capsule())
589 }
590 }
591 }
592 }
593 .padding(.vertical, 4)
594 }
595}
596
597// MARK: - Nearby Channel Row
598
599/// Row shown for groups announced by nearby peers that the local user hasn't joined.
600/// Tap to send a DR-encrypted join request to the creator (queued if they're offline).
601struct NearbyChannelRow: View {
602 @EnvironmentObject var appState: AppState
603 let channel: ChannelAnnouncement
604
605 @State private var showingRequestConfirm = false
606 @State private var requestSent = false
607
608 var body: some View {
609 Button { showingRequestConfirm = true } label: {
610 HStack(spacing: 12) {
611 ZStack {
612 Circle()
613 .fill(Color.orange.opacity(0.15))
614 .frame(width: 48, height: 48)
615 Image(systemName: "megaphone.fill")
616 .font(.system(size: 20))
617 .foregroundStyle(Color.orange)
618 }
619
620 VStack(alignment: .leading, spacing: 2) {
621 Text(channel.groupName)
622 .font(.subheadline.weight(.semibold))
623 .foregroundStyle(.primary)
624 Text(requestSent
625 ? "Request sent · waiting for creator"
626 : "\(channel.memberCount) member\(channel.memberCount == 1 ? "" : "s") · Tap to request invite")
627 .font(.caption2)
628 .foregroundStyle(requestSent ? .green : .secondary)
629 }
630 Spacer()
631 Image(systemName: requestSent ? "checkmark.circle" : "arrow.right.circle")
632 .foregroundStyle(requestSent ? .green : .tertiary)
633 }
634 }
635 .buttonStyle(.plain)
636 .padding(.vertical, 4)
637 .disabled(requestSent)
638 .confirmationDialog(
639 "Request to join "\(channel.groupName)"?",
640 isPresented: $showingRequestConfirm,
641 titleVisibility: .visible
642 ) {
643 Button("Send Join Request") {
644 appState.sendChannelJoinRequest(for: channel)
645 requestSent = true
646 }
647 Button("Cancel", role: .cancel) {}
648 } message: {
649 Text("A join request will be sent to the group creator. They'll receive it even if they're currently offline.")
650 }
651 }
652}
653
654// MARK: - Group Join Request Row
655
656/// Row shown for each pending join request received from a peer who wants to join our group.
657struct GroupJoinRequestRow: View {
658 @EnvironmentObject var appState: AppState
659 let request: ChannelJoinRequestMessage
660
661 private var requesterPeer: KnownPeer? {
662 appState.peers.first { $0.id == request.requesterPeerID }
663 }
664
665 var body: some View {
666 HStack(spacing: 12) {
667 // Avatar or generic icon
668 if let peer = requesterPeer {
669 PeerAvatar(peer: peer, size: 44)
670 } else {
671 ZStack {
672 Circle()
673 .fill(Color.blue.opacity(0.15))
674 .frame(width: 44, height: 44)
675 Image(systemName: "person.badge.plus")
676 .font(.system(size: 18))
677 .foregroundStyle(Color.blue)
678 }
679 }
680
681 VStack(alignment: .leading, spacing: 2) {
682 Text(requesterPeer.map { appState.displayName(for: $0) } ?? request.requesterUsername)
683 .font(.subheadline.weight(.medium))
684 .foregroundStyle(.primary)
685 Text("Wants to join \"\(request.groupName)\"")
686 .font(.caption2)
687 .foregroundStyle(.secondary)
688 }
689
690 Spacer()
691
692 // Approve / dismiss buttons
693 HStack(spacing: 8) {
694 Button {
695 appState.approveGroupJoinRequest(request)
696 } label: {
697 Image(systemName: "checkmark.circle.fill")
698 .font(.system(size: 26))
699 .foregroundStyle(.green)
700 }
701 .buttonStyle(.plain)
702
703 Button {
704 appState.dismissGroupJoinRequest(request)
705 } label: {
706 Image(systemName: "xmark.circle.fill")
707 .font(.system(size: 26))
708 .foregroundStyle(.secondary)
709 }
710 .buttonStyle(.plain)
711 }
712 }
713 .padding(.vertical, 4)
714 }
715}
716
717// MARK: - Conversation Row
718
719struct ConversationRow: View {
720 @EnvironmentObject var appState: AppState
721 let peer: KnownPeer
722 let messages: [StoredMessage]
723 let unreadCount: Int
724
725 var lastMessage: StoredMessage? { messages.last }
726
727 var body: some View {
728 HStack(spacing: 12) {
729 PeerAvatar(peer: peer, size: 48)
730 VStack(alignment: .leading, spacing: 2) {
731 HStack {
732 Text(appState.displayName(for: peer))
733 .font(.subheadline.weight(unreadCount > 0 ? .bold : .semibold))
734 Spacer()
735 if let last = lastMessage {
736 Text(last.timestamp, style: .relative)
737 .font(.caption2)
738 .foregroundStyle(.tertiary)
739 }
740 }
741 HStack {
742 if let last = lastMessage {
743 if last.direction == .sent {
744 statusIcon(for: last.status)
745 }
746 Text(last.body)
747 .font(.subheadline)
748 .foregroundStyle(unreadCount > 0 ? .primary : .secondary)
749 .fontWeight(unreadCount > 0 ? .medium : .regular)
750 .lineLimit(1)
751 }
752 Spacer()
753 if unreadCount > 0 {
754 Text("\(unreadCount)")
755 .font(.caption2.bold())
756 .foregroundStyle(.white)
757 .padding(.horizontal, 6)
758 .padding(.vertical, 2)
759 .background(Color.accentColor)
760 .clipShape(Capsule())
761 } else if peer.isOnline {
762 Circle().fill(.green).frame(width: 8, height: 8)
763 }
764 }
765 }
766 }
767 .padding(.vertical, 4)
768 }
769
770 @ViewBuilder
771 private func statusIcon(for status: StoredMessage.MessageStatus) -> some View {
772 switch status {
773 case .sending:
774 Image(systemName: "clock").font(.caption2).foregroundStyle(.secondary)
775 case .delivered:
776 Image(systemName: "checkmark.circle.fill").font(.caption2).foregroundStyle(.green)
777 case .read:
778 Image(systemName: "checkmark.circle.fill").font(.caption2).foregroundStyle(Color.accentColor)
779 case .failed:
780 Image(systemName: "exclamationmark.circle").font(.caption2).foregroundStyle(.red)
781 }
782 }
783}
784
785// MARK: - Peer Row (no messages yet)
786
787struct PeerRow: View {
788 @EnvironmentObject var appState: AppState
789 let peer: KnownPeer
790 let isOnline: Bool
791
792 var body: some View {
793 HStack(spacing: 12) {
794 PeerAvatar(peer: peer, size: 40)
795 VStack(alignment: .leading, spacing: 2) {
796 Text(appState.displayName(for: peer))
797 .font(.subheadline.weight(.medium))
798 Text("Tap to send a message")
799 .font(.caption)
800 .foregroundStyle(.tertiary)
801 }
802 Spacer()
803 if isOnline {
804 Circle().fill(.green).frame(width: 8, height: 8)
805 }
806 }
807 }
808}
809
810// MARK: - Contact Request Row
811
812struct ContactRequestRow: View {
813 @EnvironmentObject var appState: AppState
814 let peer: KnownPeer
815 let onAccept: () -> Void
816 let onReject: () -> Void
817
818 var body: some View {
819 HStack(spacing: 12) {
820 PeerAvatar(peer: peer, size: 44)
821 VStack(alignment: .leading, spacing: 2) {
822 Text(peer.username)
823 .font(.subheadline.weight(.semibold))
824 Text(String(peer.id.prefix(8)) + "…")
825 .font(.caption2)
826 .foregroundStyle(.secondary)
827 }
828 Spacer()
829 Button(action: onReject) {
830 Image(systemName: "xmark.circle.fill")
831 .font(.title2)
832 .foregroundStyle(.red.opacity(0.8))
833 }
834 .buttonStyle(.plain)
835 Button(action: onAccept) {
836 Image(systemName: "checkmark.circle.fill")
837 .font(.title2)
838 .foregroundStyle(.green)
839 }
840 .buttonStyle(.plain)
841 }
842 .padding(.vertical, 4)
843 }
844}
845
846// MARK: - Peer Avatar
847
848struct PeerAvatar: View {
849 @EnvironmentObject var appState: AppState
850 let peer: KnownPeer
851 let size: CGFloat
852
853 private var avatarColor: Color {
854 let hash = peer.id.prefix(6)
855 let value = Int(hash, radix: 16) ?? 0
856 let hue = Double(value % 360) / 360.0
857 return Color(hue: hue, saturation: 0.6, brightness: 0.8)
858 }
859
860 private var initials: String {
861 peer.username.prefix(1).uppercased()
862 }
863
864 var body: some View {
865 if let data = appState.peerAvatars[peer.id], let uiImage = UIImage(data: data) {
866 Image(uiImage: uiImage)
867 .resizable()
868 .scaledToFill()
869 .frame(width: size, height: size)
870 .clipShape(Circle())
871 } else {
872 ZStack {
873 Circle()
874 .fill(avatarColor.opacity(0.2))
875 .frame(width: size, height: size)
876 Text(initials)
877 .font(.system(size: size * 0.4, weight: .semibold))
878 .foregroundStyle(avatarColor)
879 }
880 }
881 }
882}
883
884// MARK: - Navigation
885
886/// Type-safe navigation destination for the AI assistant row.
887private enum AIDestination: Hashable { case assistant }
888
889/// Type-safe navigation destination for the Note to Self row.
890private enum NoteToSelfDestination: Hashable { case noteToSelf }
891
892// MARK: - Hashable conformances for NavigationLink(value:)
893
894extension KnownPeer: @retroactive Hashable {
895 public static func == (lhs: KnownPeer, rhs: KnownPeer) -> Bool { lhs.id == rhs.id }
896 public func hash(into hasher: inout Hasher) { hasher.combine(id) }
897}
898
899extension GroupInfo: @retroactive Hashable {
900 public static func == (lhs: GroupInfo, rhs: GroupInfo) -> Bool { lhs.id == rhs.id }
901 public func hash(into hasher: inout Hasher) { hasher.combine(id) }
902}
903
904// MARK: - Getting Started Card
905
906private struct GettingStartedCard: View {
907 @EnvironmentObject var appState: AppState
908 let dismiss: () -> Void
909 @State private var showingIdentity = false
910 @State private var showingScanner = false
911
912 var body: some View {
913 VStack(alignment: .leading, spacing: 0) {
914 HStack {
915 VStack(alignment: .leading, spacing: 4) {
916 Text("Get started")
917 .font(.headline)
918 Text("Connect with people nearby or anywhere in the world.")
919 .font(.subheadline)
920 .foregroundStyle(.secondary)
921 }
922 Spacer()
923 Button { dismiss() } label: {
924 Image(systemName: "xmark.circle.fill")
925 .foregroundStyle(.tertiary)
926 .font(.title3)
927 }
928 .buttonStyle(.plain)
929 }
930 .padding()
931
932 Divider()
933
934 CardActionRow(
935 icon: "square.and.arrow.up",
936 iconColor: .accentColor,
937 title: "Share my contact",
938 subtitle: "Let others add you by scanning a QR code"
939 ) { showingIdentity = true }
940
941 Divider().padding(.leading, 56)
942
943 CardActionRow(
944 icon: "qrcode.viewfinder",
945 iconColor: .green,
946 title: "Scan a QR code",
947 subtitle: "Add someone who's nearby"
948 ) { showingScanner = true }
949
950 Divider().padding(.leading, 56)
951
952 CardActionRow(
953 icon: "globe",
954 iconColor: .orange,
955 title: "Enable Global Reach",
956 subtitle: "Chat with anyone, anywhere in the world"
957 ) {
958 NotificationCenter.default.post(name: .sophaxShowSettings, object: nil)
959 }
960 }
961 .background(Color(.secondarySystemGroupedBackground))
962 .clipShape(RoundedRectangle(cornerRadius: 14))
963 .padding(.horizontal, 16)
964 .padding(.vertical, 8)
965 .sheet(isPresented: $showingIdentity) {
966 IdentityView().environmentObject(appState)
967 }
968 .sheet(isPresented: $showingScanner) {
969 ContactScannerView().environmentObject(appState)
970 }
971 }
972}
973
974private struct CardActionRow: View {
975 let icon: String
976 let iconColor: Color
977 let title: String
978 let subtitle: String
979 let action: () -> Void
980
981 var body: some View {
982 Button(action: action) {
983 HStack(spacing: 14) {
984 ZStack {
985 RoundedRectangle(cornerRadius: 8)
986 .fill(iconColor.opacity(0.12))
987 .frame(width: 36, height: 36)
988 Image(systemName: icon)
989 .font(.system(size: 16))
990 .foregroundStyle(iconColor)
991 }
992 VStack(alignment: .leading, spacing: 1) {
993 Text(title)
994 .font(.subheadline.weight(.medium))
995 .foregroundStyle(.primary)
996 Text(subtitle)
997 .font(.caption)
998 .foregroundStyle(.secondary)
999 }
1000 Spacer()
1001 Image(systemName: "chevron.right")
1002 .font(.caption.weight(.semibold))
1003 .foregroundStyle(.tertiary)
1004 }
1005 .padding(.horizontal, 16)
1006 .padding(.vertical, 10)
1007 }
1008 .buttonStyle(.plain)
1009 }
1010}
1011
1012#Preview {
1013 ChatListView().environmentObject(AppState())
1014}