1// IdentityView.swift
2// SophaxChat
3//
4// Shows the local user's identity info and safety number.
5// Also links to settings and security options.
6
7import SwiftUI
8import PhotosUI
9import UniformTypeIdentifiers
10import SophaxChatCore
11
12struct IdentityView: View {
13 @EnvironmentObject var appState: AppState
14 @Environment(\.dismiss) private var dismiss
15
16 @State private var showingRenameAlert = false
17 @State private var renameText = ""
18 @State private var avatarPickerItem: PhotosPickerItem? = nil
19
20 // Identity backup
21 @State private var exportPassphrase = ""
22 @State private var exportedBlob: IdentityTransferable? = nil
23 @State private var exportError: String? = nil
24 @State private var showingImportPicker = false
25 @State private var showingImportConfirm = false
26 @State private var pendingImportData: Data? = nil
27 @State private var importPassphrase = ""
28 @State private var importError: String? = nil
29 @State private var importSuccess = false
30
31 private var identity: IdentityManager? { appState.chatManager?.identity }
32
33 var body: some View {
34 NavigationStack {
35 List {
36 // Identity summary
37 Section {
38 if let id = identity?.publicIdentity {
39 VStack(spacing: 16) {
40 // Avatar — tap to change
41 PhotosPicker(selection: $avatarPickerItem, matching: .images) {
42 if let data = appState.myAvatarData, let uiImage = UIImage(data: data) {
43 Image(uiImage: uiImage)
44 .resizable()
45 .scaledToFill()
46 .frame(width: 80, height: 80)
47 .clipShape(Circle())
48 .overlay(Circle().stroke(Color.accentColor.opacity(0.3), lineWidth: 2))
49 } else {
50 ZStack {
51 Circle()
52 .fill(Color.accentColor.opacity(0.12))
53 .frame(width: 80, height: 80)
54 Text(String(id.username.prefix(1)).uppercased())
55 .font(.system(size: 36, weight: .bold))
56 .foregroundStyle(Color.accentColor)
57 Image(systemName: "camera.fill")
58 .font(.caption)
59 .foregroundStyle(.white)
60 .padding(5)
61 .background(Color.accentColor)
62 .clipShape(Circle())
63 .offset(x: 26, y: 26)
64 }
65 }
66 }
67 .onChange(of: avatarPickerItem) { _, item in
68 guard let item else { return }
69 Task {
70 if let data = try? await item.loadTransferable(type: Data.self),
71 let img = UIImage(data: data) {
72 appState.setMyAvatar(img)
73 }
74 avatarPickerItem = nil
75 }
76 }
77
78 VStack(spacing: 4) {
79 Button {
80 renameText = id.username
81 showingRenameAlert = true
82 } label: {
83 Text(id.username)
84 .font(.title3.bold())
85 .foregroundStyle(.primary)
86 }
87 .buttonStyle(.plain)
88 Text("Peer ID: \(id.peerID.prefix(16))…")
89 .font(.system(.caption, design: .monospaced))
90 .foregroundStyle(.secondary)
91 if appState.myAvatarData != nil {
92 Button("Remove Photo", role: .destructive) {
93 appState.removeMyAvatar()
94 }
95 .font(.caption)
96 .buttonStyle(.bordered)
97 }
98 }
99 }
100 .frame(maxWidth: .infinity)
101 .padding(.vertical, 8)
102 .listRowBackground(Color.clear)
103 }
104 }
105
106 // Share contact link
107 Section {
108 if let link = appState.generateInviteLink() {
109 ShareLink(item: link, subject: Text("Add me on SophaxChat"),
110 message: Text("Tap to add me as a contact in SophaxChat — encrypted, serverless, no account needed.")) {
111 Label("Share Contact Link", systemImage: "square.and.arrow.up")
112 }
113 }
114 } header: {
115 Text("Invite")
116 } footer: {
117 Text("Share this link so others can add you as a contact — no Bluetooth or WiFi needed.")
118 }
119
120 // Safety number
121 Section {
122 if let safetyNumber = identity?.publicIdentity.safetyNumber {
123 VStack(alignment: .leading, spacing: 10) {
124 Label("Your Safety Number", systemImage: "checkmark.shield.fill")
125 .font(.subheadline.weight(.semibold))
126 .foregroundStyle(.green)
127
128 let groups = safetyNumber.split(separator: " ")
129 LazyVGrid(columns: Array(repeating: .init(.flexible()), count: 3), spacing: 8) {
130 ForEach(groups, id: \.self) { group in
131 Text(group)
132 .font(.system(.footnote, design: .monospaced).bold())
133 .padding(8)
134 .background(Color(.tertiarySystemGroupedBackground))
135 .clipShape(RoundedRectangle(cornerRadius: 6))
136 }
137 }
138
139 Text("Share this with contacts to verify your identity out-of-band.")
140 .font(.caption)
141 .foregroundStyle(.secondary)
142 }
143 .padding(.vertical, 4)
144 }
145 } header: {
146 Text("Identity Verification")
147 }
148
149 // Identity backup
150 Section {
151 // Export
152 VStack(alignment: .leading, spacing: 8) {
153 Label("Export Identity", systemImage: "square.and.arrow.up.on.square")
154 .font(.subheadline.weight(.semibold))
155 Text("Save your encryption keys so you can restore your identity on a new device.")
156 .font(.caption)
157 .foregroundStyle(.secondary)
158 SecureField("Passphrase (min 16 chars)", text: $exportPassphrase)
159 .textContentType(.newPassword)
160 .autocorrectionDisabled()
161 if let err = exportError {
162 Text(err).font(.caption).foregroundStyle(.red)
163 }
164 if exportedBlob != nil {
165 Button {
166 // fileExporter is shown via the binding below
167 } label: {
168 Label("Share Identity File", systemImage: "square.and.arrow.up")
169 .frame(maxWidth: .infinity)
170 }
171 .buttonStyle(.borderedProminent)
172 .fileExporter(
173 isPresented: Binding(
174 get: { exportedBlob != nil },
175 set: { if !$0 { exportedBlob = nil } }
176 ),
177 document: exportedBlob!,
178 contentType: .sophaxIdentity,
179 defaultFilename: IdentityExportManager.suggestedFilename()
180 ) { _ in }
181 } else {
182 Button {
183 exportError = nil
184 let trimmed = exportPassphrase.trimmingCharacters(in: .whitespacesAndNewlines)
185 guard trimmed.count >= 16 else {
186 exportError = "Passphrase must be at least 16 characters."
187 return
188 }
189 do {
190 let data = try appState.exportIdentity(passphrase: trimmed)
191 exportedBlob = IdentityTransferable(data: data)
192 exportPassphrase = "" // clear from SwiftUI state immediately
193 } catch {
194 exportError = error.localizedDescription
195 }
196 } label: {
197 Label("Create Identity Backup", systemImage: "key.fill")
198 .frame(maxWidth: .infinity)
199 }
200 .buttonStyle(.bordered)
201 .disabled(exportPassphrase.trimmingCharacters(in: .whitespacesAndNewlines).count < 16)
202 }
203 }
204 .padding(.vertical, 4)
205
206 // Import
207 VStack(alignment: .leading, spacing: 8) {
208 Label("Restore Identity", systemImage: "square.and.arrow.down.on.square")
209 .font(.subheadline.weight(.semibold))
210 HStack(spacing: 6) {
211 Image(systemName: "exclamationmark.triangle.fill")
212 .foregroundStyle(.orange)
213 .font(.caption)
214 Text("Replaces current keys. All sessions will be reset.")
215 .font(.caption)
216 .foregroundStyle(.orange)
217 }
218 if importSuccess {
219 Label("Identity restored successfully.", systemImage: "checkmark.circle.fill")
220 .font(.caption)
221 .foregroundStyle(.green)
222 }
223 if let err = importError {
224 Text(err).font(.caption).foregroundStyle(.red)
225 }
226 Button {
227 showingImportPicker = true
228 } label: {
229 Label("Load Identity File…", systemImage: "folder")
230 .frame(maxWidth: .infinity)
231 }
232 .buttonStyle(.bordered)
233 }
234 .padding(.vertical, 4)
235 } header: {
236 Text("Identity Backup")
237 } footer: {
238 Text("The identity file contains your private keys encrypted with your passphrase. Keep it safe — anyone with the file and passphrase can impersonate you.")
239 }
240
241 // Security info
242 Section {
243 Label("End-to-end encrypted", systemImage: "lock.fill")
244 .foregroundStyle(.green)
245 Label("No servers — P2P only", systemImage: "wifi.slash")
246 .foregroundStyle(.blue)
247 Label("Anonymous — no account needed", systemImage: "person.slash")
248 .foregroundStyle(.purple)
249 Label("Keys stored in iOS Keychain", systemImage: "key.shield")
250 .foregroundStyle(.orange)
251
252 if appState.opkCount < 5 {
253 HStack {
254 Label("One-time prekeys low (\(appState.opkCount) left)", systemImage: "exclamationmark.triangle.fill")
255 .foregroundStyle(.orange)
256 Spacer()
257 Button("Regenerate") {
258 appState.chatManager?.replenishPreKeys()
259 }
260 .buttonStyle(.bordered)
261 .tint(.orange)
262 .font(.caption)
263 }
264 }
265 } header: {
266 Text("Security")
267 }
268
269 }
270 .navigationTitle("My Identity")
271 .navigationBarTitleDisplayMode(.inline)
272 .toolbar {
273 ToolbarItem(placement: .confirmationAction) {
274 Button("Done") { dismiss() }
275 }
276 }
277 .alert("Change Username", isPresented: $showingRenameAlert) {
278 TextField("New username", text: $renameText)
279 .autocorrectionDisabled()
280 .textInputAutocapitalization(.never)
281 Button("Save") {
282 appState.changeUsername(renameText)
283 }
284 Button("Cancel", role: .cancel) {}
285 } message: {
286 Text("Your new username will be shared with nearby peers.")
287 }
288 .fileImporter(
289 isPresented: $showingImportPicker,
290 allowedContentTypes: [UTType(filenameExtension: "sophaxid") ?? .data]
291 ) { result in
292 guard case .success(let url) = result,
293 url.startAccessingSecurityScopedResource(),
294 let data = try? Data(contentsOf: url) else { return }
295 url.stopAccessingSecurityScopedResource()
296 pendingImportData = data
297 showingImportConfirm = true
298 }
299 .alert("Enter Passphrase", isPresented: $showingImportConfirm) {
300 SecureField("Passphrase", text: $importPassphrase)
301 .textContentType(.password)
302 Button("Restore", role: .destructive) {
303 importError = nil
304 importSuccess = false
305 guard let data = pendingImportData else { return }
306 do {
307 try appState.importIdentity(data: data, passphrase: importPassphrase)
308 importSuccess = true
309 importPassphrase = ""
310 pendingImportData = nil
311 } catch {
312 importError = error.localizedDescription
313 importPassphrase = ""
314 }
315 }
316 Button("Cancel", role: .cancel) {
317 importPassphrase = ""
318 pendingImportData = nil
319 }
320 } message: {
321 Text("Enter the passphrase used when you exported this identity file. Your current keys will be replaced.")
322 }
323 }
324 }
325}
326
327// MARK: - FileDocument wrapper for identity export
328
329struct IdentityTransferable: FileDocument {
330 static var readableContentTypes: [UTType] { [.sophaxIdentity, .data] }
331 let data: Data
332 init(data: Data) { self.data = data }
333 init(configuration: ReadConfiguration) throws {
334 guard let d = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) }
335 self.data = d
336 }
337 func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
338 FileWrapper(regularFileWithContents: data)
339 }
340}
341
342extension UTType {
343 static let sophaxIdentity = UTType(exportedAs: "com.sophax.sophaxidentity")
344}
345
346#Preview {
347 IdentityView().environmentObject(AppState())
348}