ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

View File

@@ -0,0 +1,82 @@
import SwiftUI
struct AuthorizeDeviceView: View {
var appState: AppState
@State private var code = ""
@State private var isAuthorizing = false
@State private var statusMessage: String?
@State private var isError = false
@State private var isDone = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Image(systemName: "iphone.badge.checkmark")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Authorize New Device")
.font(.title2.bold())
Text("Enter the 8-digit pairing code shown on the new device.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TextField("Pairing Code", text: $code)
.font(.system(size: 24, weight: .bold, design: .monospaced))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
Button("Authorize") {
Task { await authorize() }
}
.buttonStyle(.borderedProminent)
.disabled(code.count < 8 || isAuthorizing || isDone)
if isAuthorizing {
ProgressView("Preparing history & sending keys...")
}
if let status = statusMessage {
Text(status)
.font(.caption)
.foregroundStyle(isError ? .red : .green)
.multilineTextAlignment(.center)
}
if isDone {
Button("Done") { dismiss() }
.buttonStyle(.bordered)
}
}
.padding(32)
}
.navigationTitle("Authorize Device")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
}
private func authorize() async {
isAuthorizing = true
isError = false
statusMessage = nil
let (success, msg) = await appState.chatClient.authorizeDevice(code: code)
isAuthorizing = false
statusMessage = msg
isError = !success
if success {
isDone = true
}
}
}

View File

@@ -0,0 +1,179 @@
import SwiftUI
struct LoginView: View {
@Bindable var viewModel: AuthViewModel
var appState: AppState
@State private var showPairing = false
@State private var didAttemptBiometric = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
.padding(.top, 40)
Text("Encrypted Chat")
.font(.largeTitle.bold())
Text("End-to-end encrypted messaging")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(spacing: 16) {
// Server config
DisclosureGroup("Server") {
TextField("Host", text: $viewModel.serverHost)
.textContentType(.URL)
.autocapitalization(.none)
TextField("Port", text: $viewModel.serverPort)
.keyboardType(.numberPad)
}
.padding(.horizontal)
if viewModel.mode == .register {
TextField("Username", text: $viewModel.username)
.textContentType(.username)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
}
TextField("Email", text: $viewModel.email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Password", text: $viewModel.password)
.textContentType(viewModel.mode == .login ? .password : .oneTimeCode)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textFieldStyle(.roundedBorder)
if viewModel.mode == .register {
SecureField("Confirm Password", text: $viewModel.confirmPassword)
.textContentType(.oneTimeCode)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textFieldStyle(.roundedBorder)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
.multilineTextAlignment(.center)
}
Button(action: {
Task {
if viewModel.mode == .login {
await viewModel.login(appState: appState)
} else {
await viewModel.register(appState: appState)
}
}
}) {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text(viewModel.mode == .login ? "Login" : "Register")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
Button(viewModel.mode == .login ? "Don't have an account? Register" : "Already have an account? Login") {
viewModel.mode = viewModel.mode == .login ? .register : .login
viewModel.errorMessage = nil
}
.font(.caption)
if viewModel.hasSavedCredentials && viewModel.mode == .login {
Divider()
.padding(.vertical, 4)
Button {
Task { await viewModel.biometricLogin(appState: appState) }
} label: {
if viewModel.isBiometricLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Sign in with Face ID", systemImage: "faceid")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.bordered)
.disabled(viewModel.isLoading || viewModel.isBiometricLoading)
}
Divider()
.padding(.vertical, 4)
Button("Pair from existing device") {
showPairing = true
}
.font(.caption)
}
.padding(.horizontal, 32)
}
}
.task {
viewModel.checkSavedCredentials()
if viewModel.hasSavedCredentials && !didAttemptBiometric {
didAttemptBiometric = true
await viewModel.biometricLogin(appState: appState)
}
}
.sheet(isPresented: $viewModel.showConfirmation) {
ConfirmationSheet(viewModel: viewModel, appState: appState)
}
.sheet(isPresented: $showPairing) {
PairingView(appState: appState, authViewModel: viewModel)
}
}
}
}
struct ConfirmationSheet: View {
@Bindable var viewModel: AuthViewModel
var appState: AppState
var body: some View {
VStack(spacing: 20) {
Text("Confirm Registration")
.font(.title2.bold())
if let msg = viewModel.registrationMessage {
Text(msg)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
TextField("Confirmation Code", text: $viewModel.confirmationCode)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
Button("Confirm") {
Task {
await viewModel.confirmRegistration(appState: appState)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
.padding(32)
}
}

View File

@@ -0,0 +1,175 @@
import SwiftUI
struct PairingView: View {
var appState: AppState
@Bindable var authViewModel: AuthViewModel
@State private var email = ""
@State private var password = ""
@State private var pairingCode: String?
@State private var isStarting = false
@State private var isWaiting = false
@State private var statusMessage: String?
@State private var isError = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Image(systemName: "iphone.and.arrow.forward")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Device Pairing")
.font(.title2.bold())
Text("Transfer your keys from an existing device to this one.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if pairingCode == nil {
// Phase 1: Enter email and start pairing
VStack(spacing: 16) {
// Server config
DisclosureGroup("Server") {
TextField("Host", text: $authViewModel.serverHost)
.textContentType(.URL)
.autocapitalization(.none)
TextField("Port", text: $authViewModel.serverPort)
.keyboardType(.numberPad)
}
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Password (for key encryption)", text: $password)
.textContentType(.password)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textFieldStyle(.roundedBorder)
Button("Start Pairing") {
Task { await startPairing() }
}
.buttonStyle(.borderedProminent)
.disabled(email.isEmpty || password.isEmpty || isStarting)
if isStarting {
ProgressView("Connecting...")
}
}
} else {
// Phase 2: Show code and wait for authorization
VStack(spacing: 16) {
Text("Pairing Code")
.font(.headline)
Text(pairingCode!)
.font(.system(size: 36, weight: .bold, design: .monospaced))
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
Text("Enter this code on your already logged-in device\nto authorize this device.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if isWaiting {
ProgressView("Waiting for authorization...")
.padding()
}
}
}
if let status = statusMessage {
Text(status)
.font(.caption)
.foregroundStyle(isError ? .red : .green)
.multilineTextAlignment(.center)
}
}
.padding(32)
}
.navigationTitle("Pair Device")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
}
private func startPairing() async {
isStarting = true
isError = false
statusMessage = nil
// Connect to server
if await !appState.chatClient.isConnected {
do {
let port = UInt16(authViewModel.serverPort) ?? Constants.defaultPort
try await appState.chatClient.connect(
host: authViewModel.serverHost, port: port
)
} catch {
isStarting = false
statusMessage = "Connection failed: \(error.localizedDescription)"
isError = true
return
}
}
let (success, codeOrMsg) = await appState.chatClient.pairingStart(email: email)
isStarting = false
if success {
pairingCode = codeOrMsg
// Start waiting for authorization
isWaiting = true
Task { await waitForAuthorization() }
} else {
statusMessage = codeOrMsg
isError = true
}
}
private func waitForAuthorization() async {
let (success, msg) = await appState.chatClient.pairingWait(
code: pairingCode!, email: email, password: password
)
isWaiting = false
if success {
statusMessage = msg
isError = false
// Auto-login
let (loginOk, loginMsg) = await appState.chatClient.login(email: email, password: password)
if loginOk {
appState.email = email
appState.isLoggedIn = true
appState.connectionStatus = .connected
appState.startConnectionMonitor()
if let userId = await appState.chatClient.userId {
appState.currentUser = User(
id: userId,
username: await appState.chatClient.username,
email: email
)
}
dismiss()
} else {
statusMessage = "Keys imported but login failed: \(loginMsg)"
isError = true
}
} else {
statusMessage = msg
isError = true
}
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Registration is handled within LoginView via mode toggle.
// This file exists for potential future separation.

View File

@@ -0,0 +1,382 @@
import SwiftUI
struct ChatView: View {
@State private var conversation: Conversation
var appState: AppState
var conversationListVM: ConversationListVM?
init(conversation: Conversation, appState: AppState, conversationListVM: ConversationListVM? = nil) {
self._conversation = State(initialValue: conversation)
self.appState = appState
self.conversationListVM = conversationListVM
}
@State private var viewModel = ChatViewModel()
@State private var inputText = ""
@State private var replyTo: Message?
@State private var showGroupInfo = false
@State private var showDMInfo = false
@State private var showSearch = false
@State private var showDeleteConfirm = false
@State private var showError = false
@State private var memberListenerTask: Task<Void, Never>?
@State private var forwardingMessage: Message?
@State private var showForwardPicker = false
@State private var showPinnedMessages = false
@State private var scrollTarget: String?
@State private var showVerification = false
@State private var verificationStatus: String = "unverified"
private var currentUserId: String {
appState.currentUser?.id ?? ""
}
private var isPartnerOnline: Bool {
guard !conversation.isGroup,
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId),
let listVM = conversationListVM else {
return false
}
return listVM.onlineUsers.contains(partnerId)
}
var body: some View {
VStack(spacing: 0) {
searchBar
messagesScrollView
replyPreview
inputView
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
.alert("Delete Conversation?", isPresented: $showDeleteConfirm) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task { await appState.chatClient.deleteConversation(convId: conversation.id) }
}
} message: {
Text(conversation.isGroup
? "This will remove all members and delete the conversation."
: "This will remove you from the conversation.")
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "Unknown error")
}
.sheet(isPresented: $showGroupInfo) {
GroupInfoView(conversation: $conversation, appState: appState, conversationListVM: conversationListVM)
}
.sheet(isPresented: $showDMInfo) {
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
ProfileView(appState: appState, isOwnProfile: false, userId: partnerId)
}
}
.sheet(isPresented: $showForwardPicker) {
if let msg = forwardingMessage {
ForwardPickerView(message: msg, appState: appState)
}
}
.sheet(isPresented: $showVerification) {
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
NavigationStack {
SafetyNumberView(
peerUserId: partnerId,
peerUsername: conversation.displayName(currentUserId: currentUserId),
chatClient: appState.chatClient
)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Done") { showVerification = false }
}
}
}
}
}
.sheet(isPresented: $showPinnedMessages) {
PinnedMessagesView(
messages: viewModel.messages.filter { $0.pinnedAt != nil },
onScrollTo: { scrollTarget = $0 }
)
}
.task {
// Use already-loaded data from conversation list (avoid redundant list_conversations call)
if let updated = conversationListVM?.conversations.first(where: { $0.id == conversation.id }) {
conversation = updated
}
conversationListVM?.markConversationRead(convId: conversation.id)
await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient)
viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient)
// Load verification status for DM partner
if !conversation.isGroup,
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
verificationStatus = await appState.chatClient.getVerificationStatus(userId: partnerId)
}
memberListenerTask = Task {
for await notification in await appState.chatClient.makeNotificationStream() {
switch notification {
case .memberAdded, .memberRemoved, .conversationRenamed:
let refreshed = await appState.chatClient.listConversations()
if let updated = refreshed.first(where: { $0.id == conversation.id }) {
await MainActor.run { conversation = updated }
}
default:
break
}
}
}
}
.onDisappear {
viewModel.stop()
memberListenerTask?.cancel()
memberListenerTask = nil
}
}
// MARK: - Search Bar
@ViewBuilder
private var searchBar: some View {
if showSearch {
SearchOverlayView(
query: $viewModel.searchQuery,
matchCount: viewModel.searchResults.count,
currentIndex: viewModel.currentSearchIndex,
onSearch: { viewModel.search(query: $0) },
onNext: { viewModel.nextSearchResult() },
onPrev: { viewModel.prevSearchResult() },
onClose: { showSearch = false; viewModel.search(query: "") }
)
}
}
// MARK: - Messages
private var messagesScrollView: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
if viewModel.messages.count >= 50 {
Button("Load older messages") {
Task {
await viewModel.loadOlderMessages(convId: conversation.id, chatClient: appState.chatClient)
}
}
.font(.caption)
.padding()
}
ForEach(viewModel.messages) { message in
messageBubble(for: message)
.id(message.id)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.onChange(of: viewModel.messages.count) {
if let lastId = viewModel.messages.last?.id {
withAnimation { proxy.scrollTo(lastId, anchor: .bottom) }
}
}
.onChange(of: scrollTarget) {
if let target = scrollTarget {
withAnimation { proxy.scrollTo(target, anchor: .center) }
scrollTarget = nil
}
}
}
}
private func messageBubble(for message: Message) -> some View {
let isCurrentSearch = viewModel.searchResults.indices.contains(viewModel.currentSearchIndex)
&& viewModel.searchResults[viewModel.currentSearchIndex] == message.id
return MessageBubbleView(
message: message,
isMine: message.isMine(currentUserId: currentUserId),
isGroup: conversation.isGroup,
isHighlighted: viewModel.searchResults.contains(message.id),
isCurrentSearchResult: isCurrentSearch,
chatClient: appState.chatClient,
currentUserId: currentUserId,
onReply: { replyTo = message },
onReact: { reaction in
Task {
await viewModel.reactToMessage(
messageId: message.id, convId: conversation.id,
reaction: reaction, currentUserId: currentUserId,
chatClient: appState.chatClient
)
}
},
onForward: {
forwardingMessage = message
showForwardPicker = true
},
onPin: { pin in
Task {
await viewModel.pinMessage(
messageId: message.id, convId: conversation.id,
pin: pin, chatClient: appState.chatClient
)
}
},
onDelete: {
Task {
await viewModel.deleteMessage(
messageId: message.id, convId: conversation.id,
chatClient: appState.chatClient
)
}
}
)
}
// MARK: - Reply Preview
@ViewBuilder
private var replyPreview: some View {
if let reply = replyTo {
HStack {
Rectangle()
.fill(.blue)
.frame(width: 3)
VStack(alignment: .leading) {
Text(reply.senderUsername)
.font(.caption.bold())
Text(reply.text ?? "")
.font(.caption)
.lineLimit(1)
}
Spacer()
Button(action: { replyTo = nil }) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
}
}
// MARK: - Input
private var inputView: some View {
MessageInputView(
text: $inputText,
isSending: viewModel.isSending,
onSend: {
Task {
let text = inputText
inputText = ""
let reply = replyTo?.id
replyTo = nil
await viewModel.sendMessage(
convId: conversation.id, text: text,
members: conversation.members,
chatClient: appState.chatClient, replyTo: reply
)
}
},
onImageSelected: { imageData in
Task {
viewModel.isSending = true
let (success, msg, sentMessage) = await appState.chatClient.sendImage(
convId: conversation.id, imageData: imageData,
members: conversation.members
)
viewModel.isSending = false
if success, let sentMessage {
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
viewModel.messages.append(sentMessage)
}
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
} else if !success {
viewModel.errorMessage = msg
showError = true
}
}
},
onFileSelected: { fileData, filename, mimeType in
Task {
viewModel.isSending = true
let (success, msg, sentMessage) = await appState.chatClient.sendFile(
convId: conversation.id, fileData: fileData,
filename: filename, mimeType: mimeType,
members: conversation.members
)
viewModel.isSending = false
if success, let sentMessage {
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
viewModel.messages.append(sentMessage)
}
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
} else if !success {
viewModel.errorMessage = msg
showError = true
}
}
},
members: conversation.members
)
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .principal) {
HStack(spacing: 8) {
CircularAvatarView(
name: conversation.displayName(currentUserId: currentUserId),
imageData: conversationListVM?.avatarCache[conversation.id],
size: 28,
isGroup: conversation.isGroup
)
Text(conversation.displayName(currentUserId: currentUserId))
.font(.headline)
if !conversation.isGroup && verificationStatus == "verified" {
Image(systemName: "checkmark.shield.fill")
.font(.caption)
.foregroundStyle(.green)
}
if isPartnerOnline {
Circle().fill(.green).frame(width: 8, height: 8)
}
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
if !conversation.isGroup {
Button(action: { showVerification = true }) {
Image(systemName: verificationStatus == "verified" ? "checkmark.shield.fill" : "shield")
.foregroundStyle(verificationStatus == "verified" ? .green : .secondary)
}
}
Button(action: { showPinnedMessages = true }) {
Image(systemName: "pin")
}
Button(action: { showSearch.toggle() }) {
Image(systemName: "magnifyingglass")
}
if conversation.isGroup {
Button(action: { showGroupInfo = true }) {
Image(systemName: "info.circle")
}
} else {
Button(action: { showDMInfo = true }) {
Image(systemName: "info.circle")
}
}
if !conversation.isGroup || conversation.createdBy == currentUserId {
Button(action: { showDeleteConfirm = true }) {
Image(systemName: "trash").foregroundStyle(.red)
}
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
import SwiftUI
struct ForwardPickerView: View {
let message: Message
let appState: AppState
@Environment(\.dismiss) private var dismiss
@State private var conversations: [Conversation] = []
@State private var isLoading = true
@State private var isSending = false
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading conversations...")
} else if conversations.isEmpty {
Text("No conversations available")
.foregroundStyle(.secondary)
} else {
List(conversations) { conv in
Button {
forwardTo(conv)
} label: {
HStack {
CircularAvatarView(
name: conv.displayName(currentUserId: appState.currentUser?.id ?? ""),
size: 36,
isGroup: conv.isGroup
)
Text(conv.displayName(currentUserId: appState.currentUser?.id ?? ""))
Spacer()
}
}
.disabled(isSending)
}
}
}
.navigationTitle("Forward to...")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.task {
conversations = await appState.chatClient.listConversations()
isLoading = false
}
}
private func forwardTo(_ conv: Conversation) {
isSending = true
Task {
let forwardPayload: [String: Any] = [
"forwarded_from": [
"sender": message.senderUsername,
"conversation_id": message.conversationId,
"message_id": message.id,
] as [String: Any]
]
let (success, _, _) = await appState.chatClient.sendMessage(
convId: conv.id,
text: message.text ?? "",
members: conv.members,
extraPayload: forwardPayload
)
await MainActor.run {
isSending = false
if success {
dismiss()
}
}
}
}
}

View File

@@ -0,0 +1,113 @@
import SwiftUI
import UIKit
import Photos
struct ImageViewerView: View {
let imageData: Data
@State private var scale: CGFloat = 1.0
@State private var saved = false
@State private var saveError: String?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
GeometryReader { geo in
if let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.gesture(
MagnifyGesture()
.onChanged { value in
scale = value.magnification
}
.onEnded { _ in
withAnimation {
scale = max(1.0, min(scale, 5.0))
}
}
)
.onTapGesture(count: 2) {
withAnimation {
scale = scale > 1 ? 1 : 2
}
}
.frame(width: geo.size.width, height: geo.size.height)
}
}
.overlay(alignment: .bottom) {
if let error = saveError {
Text(error)
.font(.caption)
.foregroundStyle(.white)
.padding(8)
.background(Capsule().fill(.red.opacity(0.8)))
.padding(.bottom, 40)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.foregroundStyle(.white)
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
// Share
if let uiImage = UIImage(data: imageData) {
ShareLink(item: Image(uiImage: uiImage), preview: SharePreview("Image", image: Image(uiImage: uiImage))) {
Image(systemName: "square.and.arrow.up")
.foregroundStyle(.white)
}
}
// Save to Photos
Button {
saveToPhotos()
} label: {
Image(systemName: saved ? "checkmark.circle.fill" : "arrow.down.to.line")
.foregroundStyle(saved ? .green : .white)
}
}
}
}
.toolbarBackground(.hidden, for: .navigationBar)
.background(.black)
}
}
private func saveToPhotos() {
guard let uiImage = UIImage(data: imageData) else {
withAnimation { saveError = "Invalid image data" }
return
}
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
DispatchQueue.main.async {
switch status {
case .authorized, .limited:
PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: uiImage)
} completionHandler: { success, error in
DispatchQueue.main.async {
if success {
withAnimation { saved = true; saveError = nil }
} else {
withAnimation { saveError = error?.localizedDescription ?? "Save failed" }
}
}
}
case .denied, .restricted:
withAnimation { saveError = "Photo library access denied. Check Settings." }
default:
withAnimation { saveError = "Photo library access required" }
}
}
}
}
}

View File

@@ -0,0 +1,558 @@
import SwiftUI
import UIKit
struct MessageBubbleView: View {
let message: Message
let isMine: Bool
var isGroup: Bool = false
var isHighlighted: Bool = false
var isCurrentSearchResult: Bool = false
var chatClient: ChatClient?
var currentUserId: String = ""
var onReply: (() -> Void)?
var onReact: ((String) -> Void)?
var onForward: (() -> Void)?
var onPin: ((Bool) -> Void)?
var onDelete: (() -> Void)?
@State private var fullImageData: Data?
@State private var showFullImage = false
@State private var isLoadingImage = false
@State private var isLoadingFile = false
@State private var downloadedFileURL: URL?
@State private var showShareSheet = false
@State private var imageError: String?
var body: some View {
HStack {
if isMine { Spacer(minLength: 60) }
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
if !isMine && isGroup {
Text(message.senderUsername)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
if message.isDeleted {
Text("Message deleted")
.font(.body.italic())
.foregroundStyle(.secondary)
.padding(12)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 16))
} else {
// Forwarded header
if let fwd = message.forwardedFrom {
HStack(spacing: 4) {
Rectangle().fill(.cyan).frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text("Forwarded from").font(.caption2).foregroundStyle(.secondary)
Text(fwd.sender).font(.caption.bold()).foregroundStyle(.cyan)
}
}
.padding(.horizontal, 8).padding(.top, 4)
}
// Reply reference
if message.replyTo != nil {
HStack(spacing: 4) {
Rectangle()
.fill(.blue.opacity(0.5))
.frame(width: 2)
Text("Reply to message")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
// Image thumbnail
if let imageInfo = message.image {
imageView(imageInfo: imageInfo)
}
// File card
if let file = message.file {
VStack(alignment: .leading, spacing: 4) {
HStack {
if isLoadingFile {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: fileIcon(for: file.filename))
}
Text(file.filename)
.lineLimit(1)
}
.font(.subheadline)
Text(formatFileSize(file.size))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(12)
.background(Color(.systemGray5))
.clipShape(RoundedRectangle(cornerRadius: 12))
.onTapGesture {
downloadAndShareFile(file: file)
}
}
// Text content with link detection
if let text = message.text, !text.isEmpty {
LinkText(text: text, isMine: isMine)
.padding(12)
.background(
isMine ? Color.blue : Color(.systemGray5)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
// Timestamp + checkmarks + reactions all on one line
HStack(spacing: 4) {
if message.pinnedAt != nil {
Image(systemName: "pin.fill").font(.caption2).foregroundStyle(.orange)
}
Text(formatTime(message.createdAt)).font(.caption2).foregroundStyle(.secondary)
if isMine {
deliveryIndicator
}
if !message.reactions.isEmpty {
inlineReactionBadges
}
}
.frame(maxWidth: .infinity, alignment: isMine ? .trailing : .leading)
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(isCurrentSearchResult ? Color.orange.opacity(0.3) :
isHighlighted ? Color.yellow.opacity(0.2) : Color.clear)
)
.contextMenu {
if !message.isDeleted {
Button(action: { onReply?() }) {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
Menu {
ForEach(ReactionEmoji.allowed, id: \.self) { key in
Button("\(ReactionEmoji.display[key] ?? "") \(key)") { onReact?(key) }
}
} label: {
Label("React", systemImage: "face.smiling")
}
Button(action: {
UIPasteboard.general.string = message.text ?? ""
// Auto-clear clipboard after 30 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
if UIPasteboard.general.string == message.text {
UIPasteboard.general.string = ""
}
}
}) {
Label("Copy", systemImage: "doc.on.doc")
}
Button(action: { onForward?() }) {
Label("Forward", systemImage: "arrowshape.turn.up.right")
}
Button(action: { onPin?(message.pinnedAt == nil) }) {
Label(message.pinnedAt == nil ? "Pin" : "Unpin",
systemImage: message.pinnedAt == nil ? "pin" : "pin.slash")
}
if isMine {
Button(role: .destructive, action: { onDelete?() }) {
Label("Delete", systemImage: "trash")
}
}
}
}
if !isMine { Spacer(minLength: 60) }
}
.sheet(isPresented: $showFullImage) {
if let data = fullImageData {
ImageViewerView(imageData: data)
}
}
.sheet(isPresented: $showShareSheet, onDismiss: {
// Clean up decrypted temp file after sharing
if let fileURL = downloadedFileURL {
try? FileManager.default.removeItem(at: fileURL)
downloadedFileURL = nil
}
}) {
if let fileURL = downloadedFileURL {
ActivityViewController(activityItems: [fileURL])
}
}
}
// MARK: - Reaction Badges (inline used in timestamp row)
private var inlineReactionBadges: some View {
let grouped = Dictionary(grouping: message.reactions, by: \.reaction)
return HStack(spacing: 2) {
ForEach(grouped.sorted(by: { $0.key < $1.key }), id: \.key) { reaction, users in
Button {
onReact?(reaction)
} label: {
Text(ReactionEmoji.display[reaction] ?? reaction)
.font(.caption)
}
.buttonStyle(.plain)
}
}
}
// MARK: - Delivery Indicator (checkmarks)
@ViewBuilder
private var deliveryIndicator: some View {
let isRead = message.readBy.contains(where: { $0 != "__delivered__" && $0 != currentUserId })
let isDelivered = message.readBy.contains("__delivered__")
if isRead {
// Read: 2 green checkmarks
HStack(spacing: -4) {
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
}
.foregroundStyle(.green)
} else if isDelivered {
// Delivered: 2 gray checkmarks
HStack(spacing: -4) {
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
}
.foregroundStyle(.secondary)
} else {
// Sent: 1 gray checkmark
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
.foregroundStyle(.secondary)
}
}
// MARK: - Image View
@ViewBuilder
private func imageView(imageInfo: ImageInfo) -> some View {
VStack(spacing: 4) {
if let thumbB64 = imageInfo.thumbnail,
let thumbData = Data(base64Encoded: thumbB64),
let uiImage = UIImage(data: thumbData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 220, maxHeight: 220)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay {
if isLoadingImage {
RoundedRectangle(cornerRadius: 12)
.fill(.black.opacity(0.4))
ProgressView()
.tint(.white)
}
}
.onTapGesture {
downloadAndShowFullImage(imageInfo: imageInfo)
}
} else {
// No thumbnail available show placeholder
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
.frame(width: 160, height: 120)
.overlay {
if isLoadingImage {
ProgressView()
} else {
VStack(spacing: 6) {
Image(systemName: "photo")
.font(.title2)
Text(imageInfo.filename)
.font(.caption)
.lineLimit(1)
}
.foregroundStyle(.secondary)
}
}
.onTapGesture {
downloadAndShowFullImage(imageInfo: imageInfo)
}
}
if let error = imageError {
Text(error)
.font(.caption2)
.foregroundStyle(.red)
.onTapGesture {
imageError = nil
downloadAndShowFullImage(imageInfo: imageInfo)
}
}
}
}
private func downloadAndShowFullImage(imageInfo: ImageInfo) {
guard !isLoadingImage, let client = chatClient else { return }
// If already downloaded, show immediately
if fullImageData != nil {
showFullImage = true
return
}
imageError = nil
isLoadingImage = true
Task {
guard let aesKey = try? ProtocolHandler.decodeBinary(imageInfo.aesKey),
let iv = try? ProtocolHandler.decodeBinary(imageInfo.iv) else {
await MainActor.run {
isLoadingImage = false
imageError = "Failed to decode image keys"
}
return
}
let data = await client.downloadFile(fileId: imageInfo.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId)
await MainActor.run {
isLoadingImage = false
if let data = data {
fullImageData = data
showFullImage = true
} else {
imageError = "Download failed, tap to retry"
}
}
}
}
// MARK: - File Download
private func downloadAndShareFile(file: FileInfo) {
guard !isLoadingFile, let client = chatClient else { return }
// If already downloaded, show share sheet immediately
if downloadedFileURL != nil {
showShareSheet = true
return
}
isLoadingFile = true
Task {
guard let aesKey = try? ProtocolHandler.decodeBinary(file.aesKey),
let iv = try? ProtocolHandler.decodeBinary(file.iv) else {
await MainActor.run { isLoadingFile = false }
return
}
let data = await client.downloadFile(fileId: file.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId)
await MainActor.run {
isLoadingFile = false
if let data = data {
// Save to temp with file protection, clean up on dismiss
let tempDir = FileManager.default.temporaryDirectory
let fileURL = tempDir.appendingPathComponent(file.filename)
try? data.write(to: fileURL, options: .completeFileProtection)
downloadedFileURL = fileURL
showShareSheet = true
}
}
}
}
private func fileIcon(for filename: String) -> String {
let ext = (filename as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "doc.richtext"
case "doc", "docx": return "doc.text"
case "xls", "xlsx": return "tablecells"
case "ppt", "pptx": return "rectangle.on.rectangle"
case "zip", "rar", "7z": return "doc.zipper"
case "mp3", "wav", "m4a": return "music.note"
case "mp4", "mov", "avi": return "film"
case "txt": return "doc.plaintext"
default: return "paperclip"
}
}
// MARK: - Helpers
private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
if Calendar.current.isDateInToday(date) {
formatter.dateFormat = "HH:mm"
} else {
formatter.dateFormat = "MMM d, HH:mm"
}
return formatter.string(from: date)
}
private func formatFileSize(_ bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}
// MARK: - Link Text
struct LinkText: View {
let text: String
let isMine: Bool
var body: some View {
Text(buildAttributedString())
.environment(\.openURL, OpenURLAction { url in
UIApplication.shared.open(url)
return .handled
})
}
private func buildAttributedString() -> AttributedString {
var result = AttributedString()
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return appendPlainWithMentions(text[text.startIndex..<text.endIndex], to: &result)
}
let nsRange = NSRange(text.startIndex..., in: text)
let matches = detector.matches(in: text, range: nsRange)
if matches.isEmpty {
return appendPlainWithMentions(text[text.startIndex..<text.endIndex], to: &result)
}
var lastEnd = text.startIndex
for match in matches {
guard let matchRange = Range(match.range, in: text),
let url = match.url else { continue }
// Plain text before link (with mention highlighting)
if lastEnd < matchRange.lowerBound {
appendPlainWithMentions(text[lastEnd..<matchRange.lowerBound], to: &result)
}
// Link
let isSecure = url.scheme?.lowercased() == "https"
var link = AttributedString(text[matchRange])
link.link = url
link.underlineStyle = .single
if isMine {
link.foregroundColor = isSecure ? .cyan : .red
} else {
link.foregroundColor = isSecure ? .blue : .red
}
result.append(link)
lastEnd = matchRange.upperBound
}
// Remaining text
if lastEnd < text.endIndex {
appendPlainWithMentions(text[lastEnd..<text.endIndex], to: &result)
}
return result
}
private static let mentionRegex = try! NSRegularExpression(pattern: "@(\\w+)", options: [])
@discardableResult
private func appendPlainWithMentions(_ substring: Substring, to result: inout AttributedString) -> AttributedString {
let str = String(substring)
let nsRange = NSRange(str.startIndex..., in: str)
let matches = Self.mentionRegex.matches(in: str, range: nsRange)
if matches.isEmpty {
var plain = AttributedString(str)
plain.foregroundColor = isMine ? .white : .primary
result.append(plain)
return result
}
let mentionColor = Color(red: 0.537, green: 0.706, blue: 0.980)
var lastEnd = str.startIndex
for match in matches {
guard let matchRange = Range(match.range, in: str) else { continue }
if lastEnd < matchRange.lowerBound {
var plain = AttributedString(str[lastEnd..<matchRange.lowerBound])
plain.foregroundColor = isMine ? .white : .primary
result.append(plain)
}
var mention = AttributedString(str[matchRange])
mention.foregroundColor = mentionColor
mention.font = .body.bold()
result.append(mention)
lastEnd = matchRange.upperBound
}
if lastEnd < str.endIndex {
var plain = AttributedString(str[lastEnd..<str.endIndex])
plain.foregroundColor = isMine ? .white : .primary
result.append(plain)
}
return result
}
}
// MARK: - Flow Layout
struct FlowLayout: Layout {
var spacing: CGFloat = 4
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
return CGSize(width: maxWidth, height: y + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX && x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
// MARK: - Share Sheet
struct ActivityViewController: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View File

@@ -0,0 +1,215 @@
import SwiftUI
import PhotosUI
import UniformTypeIdentifiers
import UIKit
struct MessageInputView: View {
@Binding var text: String
let isSending: Bool
let onSend: () -> Void
var onImageSelected: ((Data) -> Void)?
var onFileSelected: ((Data, String, String) -> Void)? // data, filename, mimeType
var members: [ConversationMember] = []
@State private var isProcessing = false
@State private var showFilePicker = false
@State private var showPhotoPicker = false
@State private var showMentionPopup = false
@State private var mentionCandidates: [ConversationMember] = []
var body: some View {
VStack(spacing: 0) {
// Mention autocomplete popup
if showMentionPopup && !mentionCandidates.isEmpty {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(mentionCandidates) { member in
Button {
completeMention(member: member)
} label: {
Text("@\(member.username)")
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
Divider()
}
}
}
.frame(maxHeight: 150)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal)
}
HStack(spacing: 8) {
// Attach button
Menu {
Button {
showPhotoPicker = true
} label: {
Label("Photo", systemImage: "photo")
}
Button {
showFilePicker = true
} label: {
Label("File", systemImage: "doc")
}
} label: {
if isProcessing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.blue)
}
}
.disabled(isProcessing || isSending)
// Text field
TextField("Message", text: $text, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.onSubmit {
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
onSend()
}
}
// Send button
Button(action: onSend) {
if isSending {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(.blue)
}
}
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.sheet(isPresented: $showPhotoPicker) {
ImagePickerView { data in
isProcessing = true
onImageSelected?(data)
isProcessing = false
}
}
.sheet(isPresented: $showFilePicker) {
DocumentPickerView { url in
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
guard let data = try? Data(contentsOf: url) else { return }
let filename = url.lastPathComponent
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
onFileSelected?(data, filename, mimeType)
}
}
.onChange(of: text) {
updateMentionCandidates()
}
} // end VStack
}
private func updateMentionCandidates() {
// Look for @prefix at end of text
guard let atRange = text.range(of: "@\\w*$", options: .regularExpression) else {
showMentionPopup = false
mentionCandidates = []
return
}
let prefix = String(text[atRange]).dropFirst().lowercased() // remove @
mentionCandidates = members.filter { member in
prefix.isEmpty || member.username.lowercased().hasPrefix(prefix)
}
showMentionPopup = !mentionCandidates.isEmpty
}
private func completeMention(member: ConversationMember) {
if let atRange = text.range(of: "@\\w*$", options: .regularExpression) {
text.replaceSubrange(atRange, with: "@\(member.username) ")
}
showMentionPopup = false
mentionCandidates = []
}
}
// MARK: - Image Picker (UIKit PHPicker wrapper)
struct ImagePickerView: UIViewControllerRepresentable {
let onImagePicked: (Data) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onImagePicked: onImagePicked)
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let onImagePicked: (Data) -> Void
init(onImagePicked: @escaping (Data) -> Void) {
self.onImagePicked = onImagePicked
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider,
provider.canLoadObject(ofClass: UIImage.self) else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
guard let uiImage = image as? UIImage,
let data = uiImage.jpegData(compressionQuality: 0.9) else { return }
DispatchQueue.main.async {
self?.onImagePicked(data)
}
}
}
}
}
// MARK: - Document Picker (UIKit wrapper)
struct DocumentPickerView: UIViewControllerRepresentable {
let onPick: (URL) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onPick: onPick)
}
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item])
picker.delegate = context.coordinator
picker.allowsMultipleSelection = false
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
class Coordinator: NSObject, UIDocumentPickerDelegate {
let onPick: (URL) -> Void
init(onPick: @escaping (URL) -> Void) {
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onPick(url)
}
}
}

View File

@@ -0,0 +1,62 @@
import SwiftUI
struct PinnedMessagesView: View {
let messages: [Message]
var onScrollTo: ((String) -> Void)?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Group {
if messages.isEmpty {
Text("No pinned messages")
.foregroundStyle(.secondary)
} else {
List(messages) { message in
Button {
dismiss()
onScrollTo?(message.id)
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "pin.fill")
.font(.caption)
.foregroundStyle(.orange)
Text(message.senderUsername)
.font(.caption.bold())
Spacer()
Text(formatTime(message.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(message.text ?? "")
.font(.body)
.lineLimit(3)
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
}
}
.navigationTitle("Pinned Messages")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
if Calendar.current.isDateInToday(date) {
formatter.dateFormat = "HH:mm"
} else {
formatter.dateFormat = "MMM d, HH:mm"
}
return formatter.string(from: date)
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct SearchOverlayView: View {
@Binding var query: String
let matchCount: Int
let currentIndex: Int
let onSearch: (String) -> Void
let onNext: () -> Void
let onPrev: () -> Void
let onClose: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search messages", text: $query)
.textFieldStyle(.roundedBorder)
.onChange(of: query) { _, newValue in
onSearch(newValue)
}
if matchCount > 0 {
Text("\(currentIndex + 1)/\(matchCount)")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize()
Button(action: onPrev) {
Image(systemName: "chevron.up")
}
Button(action: onNext) {
Image(systemName: "chevron.down")
}
}
Button(action: onClose) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct CircularAvatarView: View {
let name: String
var imageData: Data?
var size: CGFloat = 32
var isGroup: Bool = false
var body: some View {
if let imageData = imageData, let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(Circle())
} else {
// Default: colored circle with initial letter
ZStack {
Circle()
.fill(avatarColor)
.frame(width: size, height: size)
Text(initial)
.font(.system(size: size * 0.4, weight: .semibold))
.foregroundStyle(.white)
}
}
}
private var initial: String {
String(name.prefix(1)).uppercased()
}
/// Deterministic color from name hash (matching Python gui_client behavior)
private var avatarColor: Color {
let colors: [Color] = [
.red, .orange, .yellow, .green, .mint,
.teal, .cyan, .blue, .indigo, .purple, .pink
]
var hash = 0
for char in name.unicodeScalars {
hash = hash &* 31 &+ Int(char.value)
}
return colors[abs(hash) % colors.count]
}
}

View File

@@ -0,0 +1,36 @@
import SwiftUI
struct ConnectionIndicator: View {
let status: ConnectionStatus
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
if status != .connected {
Text(statusText)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var statusColor: Color {
switch status {
case .connected: return .green
case .connecting, .reconnecting: return .orange
case .disconnected: return .red
}
}
private var statusText: String {
switch status {
case .connected: return ""
case .connecting: return "Connecting..."
case .reconnecting: return "Reconnecting..."
case .disconnected: return "Disconnected"
}
}
}

View File

@@ -0,0 +1,15 @@
import SwiftUI
struct OnlineDotOverlay: View {
var size: CGFloat = 12
var body: some View {
Circle()
.fill(.green)
.frame(width: size, height: size)
.overlay(
Circle()
.stroke(.white, lineWidth: 2)
)
}
}

View File

@@ -0,0 +1,101 @@
import SwiftUI
struct ConversationListView: View {
var appState: AppState
@Bindable var viewModel: ConversationListVM
@State private var showNewConversation = false
@State private var showProfile = false
@State private var selectedConversation: Conversation?
var body: some View {
NavigationStack {
List {
// Invitations section
if !viewModel.invitations.isEmpty {
Section {
ForEach(viewModel.invitations) { invitation in
InvitationBanner(
invitation: invitation,
onAccept: {
Task {
let (success, _) = await appState.chatClient.acceptInvitation(convId: invitation.conversationId)
if success {
await viewModel.refresh(chatClient: appState.chatClient)
}
}
},
onDecline: {
Task {
_ = await appState.chatClient.declineInvitation(convId: invitation.conversationId)
await viewModel.refresh(chatClient: appState.chatClient)
}
}
)
}
} header: {
Text("Invitations")
}
}
// Conversations section
Section {
ForEach(viewModel.conversations) { conversation in
NavigationLink(value: conversation) {
ConversationRowView(
conversation: conversation,
currentUserId: appState.currentUser?.id ?? "",
isOnline: conversation.dmPartnerId(currentUserId: appState.currentUser?.id ?? "")
.map { viewModel.onlineUsers.contains($0) } ?? false,
unreadCount: viewModel.unreadCounts[conversation.id] ?? 0,
avatarData: viewModel.avatarCache[conversation.id]
)
}
.contextMenu {
Button(conversation.isFavorite ? "Remove from Favorites" : "Add to Favorites") {
viewModel.toggleFavorite(convId: conversation.id, email: appState.email)
}
}
}
}
}
.navigationTitle("Chats")
.navigationDestination(for: Conversation.self) { conversation in
ChatView(
conversation: conversation,
appState: appState,
conversationListVM: viewModel
)
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
ConnectionIndicator(status: appState.connectionStatus)
}
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button(action: { showProfile = true }) {
Image(systemName: "person.circle")
}
Button(action: { showNewConversation = true }) {
Image(systemName: "square.and.pencil")
}
}
}
}
.refreshable {
await viewModel.refresh(chatClient: appState.chatClient)
}
.sheet(isPresented: $showNewConversation) {
NewConversationSheet(appState: appState) { convId in
showNewConversation = false
await viewModel.refresh(chatClient: appState.chatClient)
}
}
.sheet(isPresented: $showProfile) {
ProfileView(appState: appState, isOwnProfile: true)
}
.task {
await viewModel.load(chatClient: appState.chatClient, email: appState.email)
}
}
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
struct ConversationRowView: View {
let conversation: Conversation
let currentUserId: String
let isOnline: Bool
let unreadCount: Int
var avatarData: Data?
var body: some View {
HStack(spacing: 12) {
// Avatar
ZStack(alignment: .bottomTrailing) {
CircularAvatarView(
name: conversation.displayName(currentUserId: currentUserId),
imageData: avatarData,
size: 44,
isGroup: conversation.isGroup
)
if isOnline && !conversation.isGroup {
OnlineDotOverlay(size: 12)
}
}
VStack(alignment: .leading, spacing: 2) {
HStack {
if conversation.isFavorite {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Text(conversation.displayName(currentUserId: currentUserId))
.font(unreadCount > 0 ? .body.bold() : .body)
.lineLimit(1)
}
if conversation.isGroup {
Text("\(conversation.members.count) members")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if unreadCount > 0 {
Text("\(unreadCount)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.blue)
.clipShape(Capsule())
}
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,100 @@
import SwiftUI
struct NewConversationSheet: View {
var appState: AppState
var onCreated: (String) async -> Void
@State private var email = ""
@State private var groupName = ""
@State private var isGroup = false
@State private var memberEmails: [String] = [""]
@State private var isLoading = false
@State private var errorMessage: String?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section {
Toggle("Create Group", isOn: $isGroup)
if isGroup {
TextField("Group Name", text: $groupName)
}
}
Section(isGroup ? "Members" : "Recipient") {
if isGroup {
ForEach(memberEmails.indices, id: \.self) { index in
TextField("Email", text: $memberEmails[index])
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
Button("Add Member") {
memberEmails.append("")
}
} else {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
}
if let error = errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.navigationTitle("New Conversation")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
Task { await create() }
}
.disabled(isLoading)
}
}
}
}
private func create() async {
isLoading = true
errorMessage = nil
let emails: [String]
if isGroup {
emails = memberEmails.map { $0.trimmed }.filter { !$0.isEmpty }
guard !emails.isEmpty else {
errorMessage = "Add at least one member"
isLoading = false
return
}
} else {
guard !email.trimmed.isEmpty else {
errorMessage = "Enter an email address"
isLoading = false
return
}
emails = [email.trimmed]
}
let name = isGroup && !groupName.trimmed.isEmpty ? groupName.trimmed : nil
let (convId, message) = await appState.chatClient.createConversation(emails: emails, name: name)
isLoading = false
if let convId = convId {
await onCreated(convId)
} else {
errorMessage = message
}
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Group creation is handled within NewConversationSheet via the isGroup toggle.
// This file exists for potential future separation.

View File

@@ -0,0 +1,301 @@
import SwiftUI
import PhotosUI
struct GroupInfoView: View {
@Binding var conversation: Conversation
var appState: AppState
var conversationListVM: ConversationListVM?
@State private var showRenameSheet = false
@State private var showLeaveConfirm = false
@State private var showAddMember = false
@State private var showRemoveConfirm = false
@State private var showAvatarPicker = false
@State private var newName = ""
@State private var addMemberEmail = ""
@State private var memberToRemove: ConversationMember?
@State private var errorMessage: String?
@State private var showError = false
@State private var isUploadingAvatar = false
@State private var groupAvatarData: Data?
@Environment(\.dismiss) private var dismiss
private var isCreator: Bool {
conversation.createdBy == appState.currentUser?.id
}
private func refreshConversation() async {
let convs = await appState.chatClient.listConversations()
if let updated = convs.first(where: { $0.id == conversation.id }) {
conversation = updated
}
await conversationListVM?.forceRefresh(chatClient: appState.chatClient)
}
var body: some View {
NavigationStack {
List {
// Avatar section
Section {
HStack {
Spacer()
VStack(spacing: 8) {
CircularAvatarView(
name: conversation.name ?? "Group",
imageData: groupAvatarData ?? conversationListVM?.avatarCache[conversation.id],
size: 64,
isGroup: true
)
Text(conversation.name ?? "Group")
.font(.title2.bold())
Text("\(conversation.members.count) members")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.listRowBackground(Color.clear)
}
// Actions
if isCreator {
Section {
Button("Add Member") {
addMemberEmail = ""
showAddMember = true
}
Button("Rename Group") {
newName = conversation.name ?? ""
showRenameSheet = true
}
Button {
showAvatarPicker = true
} label: {
HStack {
Text("Change Avatar")
if isUploadingAvatar {
Spacer()
ProgressView()
.scaleEffect(0.8)
}
}
}
.disabled(isUploadingAvatar)
}
}
// Members
Section("Members") {
ForEach(conversation.members) { member in
HStack {
CircularAvatarView(name: member.username, size: 32, isGroup: false)
VStack(alignment: .leading) {
Text(member.username)
.font(.body)
Text(member.email)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if member.userId == conversation.createdBy {
Text("Admin")
.font(.caption)
.foregroundStyle(.blue)
}
}
.contextMenu {
if isCreator && member.userId != appState.currentUser?.id {
Button("Remove from Group", role: .destructive) {
memberToRemove = member
showRemoveConfirm = true
}
}
}
}
}
// Leave / Delete
Section {
Button("Leave Group", role: .destructive) {
showLeaveConfirm = true
}
if isCreator {
Button("Delete Group", role: .destructive) {
Task {
_ = await appState.chatClient.deleteConversation(convId: conversation.id)
dismiss()
}
}
}
}
}
.navigationTitle("Group Info")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
.alert("Leave Group?", isPresented: $showLeaveConfirm) {
Button("Cancel", role: .cancel) {}
Button("Leave", role: .destructive) {
Task {
_ = await appState.chatClient.leaveGroup(convId: conversation.id)
dismiss()
}
}
}
.alert("Remove Member?", isPresented: $showRemoveConfirm) {
Button("Cancel", role: .cancel) {}
Button("Remove", role: .destructive) {
if let member = memberToRemove {
Task {
let (success, msg) = await appState.chatClient.removeMember(
convId: conversation.id, userId: member.userId
)
if success {
await refreshConversation()
} else {
errorMessage = msg
showError = true
}
}
}
}
} message: {
if let member = memberToRemove {
Text("Remove \(member.username) from the group?")
}
}
.alert("Add Member", isPresented: $showAddMember) {
TextField("Email", text: $addMemberEmail)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
Button("Cancel", role: .cancel) {}
Button("Add") {
let email = addMemberEmail.trimmingCharacters(in: .whitespacesAndNewlines)
guard !email.isEmpty else { return }
Task {
let (success, msg) = await appState.chatClient.addMember(
convId: conversation.id, email: email
)
if success {
await refreshConversation()
} else {
errorMessage = msg
showError = true
}
}
}
}
.alert("Rename Group", isPresented: $showRenameSheet) {
TextField("Group Name", text: $newName)
Button("Cancel", role: .cancel) {}
Button("Rename") {
let trimmedName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
// Optimistic update - immediately reflect in UI
conversation.name = trimmedName
Task {
let (success, _) = await appState.chatClient.renameConversation(convId: conversation.id, name: trimmedName)
if success {
await refreshConversation()
} else {
// Revert on failure
await refreshConversation()
}
}
}
}
.alert("Error", isPresented: $showError) {
Button("OK") {}
} message: {
Text(errorMessage ?? "")
}
.sheet(isPresented: $showAvatarPicker) {
AvatarPickerView { imageData in
isUploadingAvatar = true
Task {
let success = await appState.chatClient.updateGroupAvatar(
convId: conversation.id, imageData: imageData
)
isUploadingAvatar = false
if success {
// Update local avatar cache (memory + disk)
groupAvatarData = imageData
conversationListVM?.updateAvatar(convId: conversation.id, data: imageData)
await refreshConversation()
} else {
errorMessage = "Failed to update avatar"
showError = true
}
}
}
}
.task {
// Load current group avatar
if groupAvatarData == nil, let cached = conversationListVM?.avatarCache[conversation.id] {
groupAvatarData = cached
} else if groupAvatarData == nil {
groupAvatarData = await appState.chatClient.getGroupAvatar(convId: conversation.id)
}
await refreshConversation()
}
}
}
}
// MARK: - Avatar Picker (PHPicker wrapper for avatar selection)
private struct AvatarPickerView: UIViewControllerRepresentable {
let onImagePicked: (Data) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onImagePicked: onImagePicked)
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let onImagePicked: (Data) -> Void
init(onImagePicked: @escaping (Data) -> Void) {
self.onImagePicked = onImagePicked
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let provider = results.first?.itemProvider,
provider.canLoadObject(ofClass: UIImage.self) else {
picker.dismiss(animated: true)
return
}
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
guard let uiImage = image as? UIImage,
let data = uiImage.jpegData(compressionQuality: 0.8) else {
DispatchQueue.main.async { picker.dismiss(animated: true) }
return
}
DispatchQueue.main.async {
self?.onImagePicked(data)
picker.dismiss(animated: true)
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
struct InvitationBanner: View {
let invitation: Invitation
let onAccept: () -> Void
let onDecline: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "envelope.badge")
.foregroundStyle(.orange)
VStack(alignment: .leading) {
Text(invitation.conversationName)
.font(.body.bold())
Text("Invited by \(invitation.invitedByUsername)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
HStack(spacing: 12) {
Button("Accept") {
onAccept()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button("Decline") {
onDecline()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Profile editing is handled within ProfileView when isOwnProfile = true.
// This file exists for potential future separation.

View File

@@ -0,0 +1,277 @@
import SwiftUI
import PhotosUI
import UIKit
struct ProfileView: View {
var appState: AppState
var isOwnProfile: Bool
var userId: String?
@State private var viewModel = ProfileViewModel()
@State private var showLogoutConfirm = false
@State private var showAvatarPicker = false
@State private var showAuthorizeDevice = false
@State private var showRotateKeys = false
@State private var rotatePassword = ""
@State private var isRotating = false
@State private var rotateMessage: String?
@State private var rotateIsError = false
@State private var showChangeUsername = false
@State private var newUsername = ""
@State private var showChangePassword = false
@State private var oldPassword = ""
@State private var newPassword = ""
@State private var confirmNewPassword = ""
@State private var showVerification = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
// Avatar
Section {
HStack {
Spacer()
VStack(spacing: 8) {
if let avatarData = viewModel.avatarData,
let uiImage = UIImage(data: avatarData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(Circle())
} else {
CircularAvatarView(
name: viewModel.profile?.username ?? "?",
size: 80,
isGroup: false
)
}
if isOwnProfile {
Button("Change Photo") {
showAvatarPicker = true
}
.font(.caption)
}
}
Spacer()
}
.listRowBackground(Color.clear)
}
// Info
Section("Info") {
if let username = viewModel.profile?.username {
LabeledContent("Username", value: username)
}
if let email = viewModel.profile?.email {
LabeledContent("Email", value: email)
}
}
if isOwnProfile {
// Editable fields
Section("Contact") {
TextField("Phone", text: $viewModel.phone)
.keyboardType(.phonePad)
Toggle("Phone visible to contacts", isOn: $viewModel.phoneVisible)
TextField("Location", text: $viewModel.location)
Toggle("Location visible to contacts", isOn: $viewModel.locationVisible)
}
} else {
// Read-only view
if let phone = viewModel.profile?.phone, viewModel.profile?.phoneVisible == true {
Section("Contact") {
LabeledContent("Phone", value: phone)
}
}
if let location = viewModel.profile?.location, viewModel.profile?.locationVisible == true {
Section("Location") {
LabeledContent("Location", value: location)
}
}
}
if !isOwnProfile, let uid = userId {
Section("Security") {
NavigationLink {
SafetyNumberView(
peerUserId: uid,
peerUsername: viewModel.profile?.username ?? "User",
chatClient: appState.chatClient
)
} label: {
Label("Verify Identity", systemImage: "checkmark.shield")
}
}
}
if let error = viewModel.errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
}
}
if isOwnProfile {
Section("Account") {
Button {
newUsername = viewModel.profile?.username ?? ""
showChangeUsername = true
} label: {
Label("Change Username", systemImage: "person.text.rectangle")
}
Button {
showChangePassword = true
} label: {
Label("Change Password", systemImage: "key")
}
}
Section("Security") {
Button {
showAuthorizeDevice = true
} label: {
Label("Authorize New Device", systemImage: "iphone.badge.checkmark")
}
Button {
showRotateKeys = true
} label: {
Label("Rotate Keys", systemImage: "arrow.triangle.2.circlepath")
}
}
Section {
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
HStack {
Spacer()
Text("Logout")
Spacer()
}
}
}
}
}
.navigationTitle(isOwnProfile ? "My Profile" : "Profile")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if isOwnProfile {
Button("Save") {
Task {
let success = await viewModel.saveProfile(chatClient: appState.chatClient)
if success {
dismiss()
}
}
}
.disabled(viewModel.isSaving)
}
}
ToolbarItem(placement: .topBarLeading) {
if !isOwnProfile {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
}
}
}
}
.alert("Logout", isPresented: $showLogoutConfirm) {
Button("Cancel", role: .cancel) {}
Button("Logout", role: .destructive) {
Task {
await appState.logout()
}
}
} message: {
Text("Are you sure you want to logout?")
}
.sheet(isPresented: $showAvatarPicker) {
ImagePickerView { data in
Task {
await viewModel.uploadAvatar(imageData: data, chatClient: appState.chatClient)
}
}
}
.sheet(isPresented: $showAuthorizeDevice) {
AuthorizeDeviceView(appState: appState)
}
.alert("Rotate Keys", isPresented: $showRotateKeys) {
SecureField("Password", text: $rotatePassword)
Button("Cancel", role: .cancel) { rotatePassword = "" }
Button("Rotate") {
Task {
isRotating = true
let (success, msg) = await appState.chatClient.rotateKeys(password: rotatePassword)
rotatePassword = ""
isRotating = false
rotateMessage = msg
rotateIsError = !success
}
}
} message: {
Text("Enter your password to generate new keys. All other devices will be disconnected.")
}
.alert(rotateIsError ? "Error" : "Success", isPresented: Binding(
get: { rotateMessage != nil },
set: { if !$0 { rotateMessage = nil } }
)) {
Button("OK") { rotateMessage = nil }
} message: {
Text(rotateMessage ?? "")
}
.alert("Change Username", isPresented: $showChangeUsername) {
TextField("New username", text: $newUsername)
Button("Cancel", role: .cancel) { newUsername = "" }
Button("Change") {
Task {
let success = await viewModel.changeUsername(newUsername: newUsername, chatClient: appState.chatClient)
if success {
await viewModel.loadProfile(chatClient: appState.chatClient)
}
newUsername = ""
}
}
} message: {
Text("Enter a new display name.")
}
.alert("Change Password", isPresented: $showChangePassword) {
SecureField("Current password", text: $oldPassword)
SecureField("New password", text: $newPassword)
SecureField("Confirm new password", text: $confirmNewPassword)
Button("Cancel", role: .cancel) {
oldPassword = ""
newPassword = ""
confirmNewPassword = ""
}
Button("Change") {
Task {
guard newPassword == confirmNewPassword else {
viewModel.errorMessage = "Passwords don't match"
return
}
_ = await viewModel.changePassword(
oldPassword: oldPassword, newPassword: newPassword,
chatClient: appState.chatClient
)
oldPassword = ""
newPassword = ""
confirmNewPassword = ""
}
}
} message: {
Text("Enter your current password and a new password.")
}
.task {
await viewModel.loadProfile(userId: userId, chatClient: appState.chatClient)
}
}
}
}

View File

@@ -0,0 +1,149 @@
import SwiftUI
import AVFoundation
struct QRCodeScannerView: View {
let onScan: (Data) -> Void
@Environment(\.dismiss) private var dismiss
@State private var cameraPermission: CameraPermission = .unknown
enum CameraPermission {
case unknown, granted, denied
}
var body: some View {
NavigationStack {
ZStack {
switch cameraPermission {
case .unknown:
ProgressView("Requesting camera access...")
case .denied:
VStack(spacing: 16) {
Image(systemName: "camera.fill")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Camera access is required to scan QR codes.")
.multilineTextAlignment(.center)
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
}
.padding()
case .granted:
ScannerRepresentable(onScan: { data in
onScan(data)
dismiss()
})
.ignoresSafeArea()
}
}
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
.task {
await checkCameraPermission()
}
}
private func checkCameraPermission() async {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
cameraPermission = .granted
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: .video)
cameraPermission = granted ? .granted : .denied
default:
cameraPermission = .denied
}
}
}
// MARK: - Scanner UIKit wrapper
private struct ScannerRepresentable: UIViewControllerRepresentable {
let onScan: (Data) -> Void
func makeUIViewController(context: Context) -> ScannerViewController {
let vc = ScannerViewController()
vc.onScan = onScan
return vc
}
func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {}
}
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onScan: ((Data) -> Void)?
private var captureSession: AVCaptureSession?
private var previewLayer: AVCaptureVideoPreviewLayer?
private var hasScanned = false
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupCamera()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
captureSession?.stopRunning()
}
private func setupCamera() {
let session = AVCaptureSession()
captureSession = session
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
return
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.bounds
view.layer.addSublayer(layer)
previewLayer = layer
DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard !hasScanned,
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
object.type == .qr else { return }
hasScanned = true
captureSession?.stopRunning()
// QR codes contain base64-encoded binary data (matching Python client)
if let stringValue = object.stringValue,
let data = Data(base64Encoded: stringValue) {
onScan?(data)
}
}
}

View File

@@ -0,0 +1,144 @@
import SwiftUI
import CoreImage.CIFilterBuiltins
struct SafetyNumberView: View {
let peerUserId: String
let peerUsername: String
var chatClient: ChatClient
@State private var vm = VerificationVM()
@State private var showQRScanner = false
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Verification status badge
VerificationStatusView(status: vm.verificationStatus)
.padding(.top)
// Safety number
if let safetyNumber = vm.safetyNumber {
VStack(spacing: 8) {
Text("Safety Number")
.font(.headline)
Text("If both you and \(peerUsername) see the same number, your communication is secure.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Text(safetyNumber)
.font(.system(.title2, design: .monospaced))
.padding()
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// QR Code
if let qrData = vm.qrCodeData {
VStack(spacing: 8) {
Text("Your QR Code")
.font(.headline)
if let qrImage = generateQRCode(from: qrData) {
Image(uiImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
.padding()
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// Fingerprints
VStack(spacing: 12) {
if let myFP = vm.myFingerprint {
VStack(spacing: 4) {
Text("Your Fingerprint")
.font(.subheadline.bold())
Text(myFP)
.font(.system(.caption, design: .monospaced))
}
}
if let peerFP = vm.peerFingerprint {
VStack(spacing: 4) {
Text("\(peerUsername)'s Fingerprint")
.font(.subheadline.bold())
Text(peerFP)
.font(.system(.caption, design: .monospaced))
}
}
}
// Actions
VStack(spacing: 12) {
if vm.verificationStatus != "verified" {
Button {
Task { await vm.verifyContact(peerUserId: peerUserId, chatClient: chatClient) }
} label: {
Label("Mark as Verified", systemImage: "checkmark.shield.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
showQRScanner = true
} label: {
Label("Scan QR Code", systemImage: "qrcode.viewfinder")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
} else {
Button(role: .destructive) {
Task { await vm.unverifyContact(peerUserId: peerUserId, chatClient: chatClient) }
} label: {
Label("Remove Verification", systemImage: "xmark.shield")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
.padding(.horizontal)
// Scan result
if let result = vm.scanResult {
Text(result)
.font(.callout)
.foregroundStyle(vm.scanSuccess == true ? .green : .red)
.padding()
}
}
.padding()
}
.navigationTitle("Verify \(peerUsername)")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showQRScanner) {
QRCodeScannerView { scannedData in
showQRScanner = false
Task { await vm.verifyQRCode(data: scannedData, chatClient: chatClient) }
}
}
.task {
await vm.loadVerification(peerUserId: peerUserId, chatClient: chatClient)
}
}
private func generateQRCode(from data: Data) -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
// Base64-encode binary data raw binary gets corrupted by QR readers (UTF-8 re-encoding)
let b64String = data.base64EncodedString()
filter.setValue(b64String.data(using: .ascii), forKey: "inputMessage")
filter.setValue("M", forKey: "inputCorrectionLevel")
guard let outputImage = filter.outputImage else { return nil }
let scale = 200.0 / outputImage.extent.width
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
struct VerificationStatusView: View {
let status: String // "verified", "trusted", "unverified"
var body: some View {
HStack(spacing: 6) {
Image(systemName: iconName)
.foregroundStyle(iconColor)
Text(displayText)
.font(.subheadline.bold())
.foregroundStyle(iconColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(iconColor.opacity(0.12))
.clipShape(Capsule())
}
private var iconName: String {
switch status {
case "verified": return "checkmark.shield.fill"
case "trusted": return "shield.fill"
default: return "shield.slash"
}
}
private var iconColor: Color {
switch status {
case "verified": return .green
case "trusted": return .blue
default: return .secondary
}
}
private var displayText: String {
switch status {
case "verified": return "Verified"
case "trusted": return "Trusted"
default: return "Unverified"
}
}
}