initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import Foundation
import SwiftUI
@Observable
final class AuthViewModel {
var email = ""
var password = ""
var confirmPassword = ""
var username = ""
var confirmationCode = ""
var isLoading = false
var errorMessage: String?
var showConfirmation = false
var registrationMessage: String?
var serverHost = Constants.defaultHost
var serverPort = String(Constants.defaultPort)
var useTLS = false
enum AuthMode {
case login, register, pairing
}
var mode: AuthMode = .login
func login(appState: AppState) async {
guard !email.isEmpty, !password.isEmpty else {
errorMessage = "Email and password are required"
return
}
isLoading = true
errorMessage = nil
do {
let port = UInt16(serverPort) ?? Constants.defaultPort
try await appState.chatClient.connect(host: serverHost, port: port, useTLS: useTLS)
} catch {
isLoading = false
errorMessage = "Connection failed: \(error.localizedDescription)"
return
}
let (success, message) = await appState.chatClient.login(email: email, password: password)
isLoading = false
if success {
appState.email = email
appState.isLoggedIn = true
appState.connectionStatus = .connected
if let userId = await appState.chatClient.userId {
appState.currentUser = User(id: userId, username: await appState.chatClient.username, email: email)
}
} else {
errorMessage = message
}
}
func register(appState: AppState) async {
guard !email.isEmpty, !password.isEmpty, !username.isEmpty else {
errorMessage = "All fields are required"
return
}
guard password == confirmPassword else {
errorMessage = "Passwords don't match"
return
}
isLoading = true
errorMessage = nil
do {
let port = UInt16(serverPort) ?? Constants.defaultPort
try await appState.chatClient.connect(host: serverHost, port: port, useTLS: useTLS)
} catch {
isLoading = false
errorMessage = "Connection failed: \(error.localizedDescription)"
return
}
let (success, message) = await appState.chatClient.register(username: username, password: password, email: email)
isLoading = false
if success {
registrationMessage = message
showConfirmation = true
} else {
errorMessage = message
}
}
func confirmRegistration(appState: AppState) async {
guard !confirmationCode.isEmpty else {
errorMessage = "Enter the confirmation code"
return
}
isLoading = true
errorMessage = nil
let (success, message) = await appState.chatClient.confirmRegistration(
email: email, username: username, code: confirmationCode
)
isLoading = false
if success {
registrationMessage = message
// Auto-login after registration
await login(appState: appState)
} else {
errorMessage = message
}
}
}

View File

@@ -0,0 +1,131 @@
import Foundation
import SwiftUI
@Observable
final class ChatViewModel {
var messages: [Message] = []
var isLoading = false
var isSending = false
var errorMessage: String?
var searchQuery = ""
var searchResults: [String] = [] // message IDs matching search
var currentSearchIndex = 0
private var notificationTask: Task<Void, Never>?
func loadMessages(convId: String, chatClient: ChatClient) async {
isLoading = true
messages = await chatClient.getMessages(convId: convId, limit: 50)
isLoading = false
// Mark as read
let unreadIds = messages.filter { !$0.isMine(currentUserId: await chatClient.userId ?? "") }.map(\.id)
if !unreadIds.isEmpty {
await chatClient.markRead(convId: convId, messageIds: unreadIds)
}
}
func loadOlderMessages(convId: String, chatClient: ChatClient) async {
let older = await chatClient.getMessages(convId: convId, limit: 50, offset: messages.count)
messages.insert(contentsOf: older, at: 0)
}
func sendMessage(convId: String, text: String, members: [ConversationMember],
chatClient: ChatClient, replyTo: String? = nil) async {
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isSending = true
errorMessage = nil
let (success, msg) = await chatClient.sendMessage(
convId: convId, text: text, members: members, replyTo: replyTo
)
isSending = false
if !success {
errorMessage = msg
} else {
// Reload messages to get the sent message
await loadMessages(convId: convId, chatClient: chatClient)
}
}
func deleteMessage(messageId: String, convId: String, chatClient: ChatClient) async {
let success = await chatClient.deleteMessage(messageId: messageId, convId: convId)
if success {
messages.removeAll { $0.id == messageId }
}
}
func search(query: String) {
searchQuery = query
if query.isEmpty {
searchResults = []
currentSearchIndex = 0
return
}
let lower = query.lowercased()
searchResults = messages.filter { $0.text?.lowercased().contains(lower) == true }.map(\.id)
currentSearchIndex = searchResults.isEmpty ? 0 : searchResults.count - 1
}
func nextSearchResult() {
guard !searchResults.isEmpty else { return }
currentSearchIndex = (currentSearchIndex + 1) % searchResults.count
}
func prevSearchResult() {
guard !searchResults.isEmpty else { return }
currentSearchIndex = (currentSearchIndex - 1 + searchResults.count) % searchResults.count
}
func startNotificationListener(convId: String, chatClient: ChatClient) {
notificationTask?.cancel()
notificationTask = Task {
for await notification in await chatClient.notifications {
await handleNotification(notification, convId: convId, chatClient: chatClient)
}
}
}
@MainActor
private func handleNotification(_ notification: ChatNotification, convId: String, chatClient: ChatClient) {
switch notification {
case .newMessage(let data):
if data["conversation_id"] as? String == convId {
if let msg = Task.detached(priority: .userInitiated, operation: {
await chatClient.decryptNotification(data)
}) as? Task<Message?, Never> {
Task {
if let message = await msg.value {
messages.append(message)
// Mark as read immediately since we're viewing this conv
await chatClient.markRead(convId: convId, messageIds: [message.id])
}
}
}
}
case .messageDeleted(let data):
if let msgId = data["message_id"] as? String {
messages.removeAll { $0.id == msgId }
}
case .messagesRead(let data):
if let readUserId = data["user_id"] as? String,
let msgIds = data["message_ids"] as? [String] {
for i in messages.indices {
if msgIds.contains(messages[i].id) {
messages[i].readBy.insert(readUserId)
}
}
}
default:
break
}
}
func stop() {
notificationTask?.cancel()
notificationTask = nil
}
}

View File

@@ -0,0 +1,127 @@
import Foundation
import SwiftUI
@Observable
final class ConversationListVM {
var conversations: [Conversation] = []
var invitations: [Invitation] = []
var onlineUsers: Set<String> = []
var unreadCounts: [String: Int] = [:]
var favorites: Set<String> = []
var isLoading = false
private var notificationTask: Task<Void, Never>?
func load(chatClient: ChatClient, email: String) async {
isLoading = true
// Load favorites from disk
favorites = KeyStorage.loadFavorites(email: email)
// Fetch conversations
let convs = await chatClient.listConversations()
conversations = sortConversations(convs, currentUserId: await chatClient.userId ?? "")
// Populate unread counts from server
for conv in conversations where conv.unreadCount > 0 {
unreadCounts[conv.id] = conv.unreadCount
}
// Fetch invitations
invitations = await chatClient.listInvitations()
isLoading = false
// Start notification listener
startNotificationListener(chatClient: chatClient, email: email)
}
func refresh(chatClient: ChatClient) async {
let convs = await chatClient.listConversations()
conversations = sortConversations(convs, currentUserId: await chatClient.userId ?? "")
invitations = await chatClient.listInvitations()
}
func toggleFavorite(convId: String, email: String) {
if favorites.contains(convId) {
favorites.remove(convId)
} else {
favorites.insert(convId)
}
try? KeyStorage.saveFavorites(email: email, favorites: favorites)
// Re-sort
let userId = conversations.first?.createdBy ?? ""
conversations = sortConversations(conversations, currentUserId: userId)
}
func markConversationRead(convId: String) {
unreadCounts[convId] = 0
}
func incrementUnread(convId: String) {
unreadCounts[convId, default: 0] += 1
}
private func sortConversations(_ convs: [Conversation], currentUserId: String) -> [Conversation] {
var result = convs.map { conv -> Conversation in
var c = conv
c.isFavorite = favorites.contains(conv.id)
c.unreadCount = unreadCounts[conv.id] ?? conv.unreadCount
return c
}
result.sort { a, b in
// Favorites first
if a.isFavorite != b.isFavorite { return a.isFavorite }
// Online DMs next
let aOnline = a.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
let bOnline = b.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
if aOnline != bOnline { return aOnline }
// Alphabetical
return a.displayName(currentUserId: currentUserId).lowercased() < b.displayName(currentUserId: currentUserId).lowercased()
}
return result
}
private func startNotificationListener(chatClient: ChatClient, email: String) {
notificationTask?.cancel()
notificationTask = Task {
for await notification in await chatClient.notifications {
await handleNotification(notification, chatClient: chatClient, email: email)
}
}
}
@MainActor
private func handleNotification(_ notification: ChatNotification, chatClient: ChatClient, email: String) {
switch notification {
case .newMessage(let data):
if let convId = data["conversation_id"] as? String {
incrementUnread(convId: convId)
}
case .onlineUsers(let userIds):
onlineUsers = Set(userIds)
case .userOnline(let userId):
onlineUsers.insert(userId)
case .userOffline(let userId):
onlineUsers.remove(userId)
case .conversationCreated, .memberAdded, .memberRemoved, .conversationRenamed:
Task { await refresh(chatClient: chatClient) }
case .groupInvitation:
Task { invitations = await chatClient.listInvitations() }
case .connectionStateChanged(let connected):
if !connected {
// Could trigger auto-reconnect here
}
default:
break
}
}
func stop() {
notificationTask?.cancel()
notificationTask = nil
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
import SwiftUI
@Observable
final class ProfileViewModel {
var profile: UserProfile?
var avatarData: Data?
var isLoading = false
var isSaving = false
var errorMessage: String?
// Editable fields
var phone = ""
var phoneVisible = false
var location = ""
var locationVisible = false
func loadProfile(userId: String? = nil, chatClient: ChatClient) async {
isLoading = true
profile = await chatClient.getProfile(userId: userId)
isLoading = false
if let p = profile {
phone = p.phone ?? ""
phoneVisible = p.phoneVisible
location = p.location ?? ""
locationVisible = p.locationVisible
}
// Load avatar
let uid = userId ?? await chatClient.userId ?? ""
if !uid.isEmpty {
avatarData = await chatClient.getAvatar(userId: uid)
}
}
func saveProfile(chatClient: ChatClient) async {
isSaving = true
errorMessage = nil
let success = await chatClient.updateProfile(
phone: phone.isEmpty ? nil : phone,
phoneVisible: phoneVisible,
location: location.isEmpty ? nil : location,
locationVisible: locationVisible
)
isSaving = false
if !success {
errorMessage = "Failed to update profile"
}
}
func uploadAvatar(imageData: Data, chatClient: ChatClient) async {
isSaving = true
let success = await chatClient.updateAvatar(imageData: imageData)
isSaving = false
if success {
avatarData = imageData
} else {
errorMessage = "Failed to upload avatar"
}
}
}