ios_client
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
179
ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift
Normal file
179
ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
175
ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift
Normal file
175
ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
4
ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift
Normal file
4
ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift
Normal file
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Registration is handled within LoginView via mode toggle.
|
||||
// This file exists for potential future separation.
|
||||
382
ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift
Normal file
382
ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
ios_client 0.8.5/Kecalek/Views/Chat/ForwardPickerView.swift
Normal file
77
ios_client 0.8.5/Kecalek/Views/Chat/ForwardPickerView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
ios_client 0.8.5/Kecalek/Views/Chat/ImageViewerView.swift
Normal file
113
ios_client 0.8.5/Kecalek/Views/Chat/ImageViewerView.swift
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
558
ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift
Normal file
558
ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift
Normal 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) {}
|
||||
}
|
||||
215
ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift
Normal file
215
ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
62
ios_client 0.8.5/Kecalek/Views/Chat/PinnedMessagesView.swift
Normal file
62
ios_client 0.8.5/Kecalek/Views/Chat/PinnedMessagesView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
46
ios_client 0.8.5/Kecalek/Views/Chat/SearchOverlayView.swift
Normal file
46
ios_client 0.8.5/Kecalek/Views/Chat/SearchOverlayView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Group creation is handled within NewConversationSheet via the isGroup toggle.
|
||||
// This file exists for potential future separation.
|
||||
301
ios_client 0.8.5/Kecalek/Views/Groups/GroupInfoView.swift
Normal file
301
ios_client 0.8.5/Kecalek/Views/Groups/GroupInfoView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
ios_client 0.8.5/Kecalek/Views/Groups/InvitationBanner.swift
Normal file
41
ios_client 0.8.5/Kecalek/Views/Groups/InvitationBanner.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Profile editing is handled within ProfileView when isOwnProfile = true.
|
||||
// This file exists for potential future separation.
|
||||
277
ios_client 0.8.5/Kecalek/Views/Profile/ProfileView.swift
Normal file
277
ios_client 0.8.5/Kecalek/Views/Profile/ProfileView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user