ios_client

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

View File

@@ -0,0 +1,54 @@
import Foundation
struct Conversation: Identifiable, Equatable, Hashable, Codable {
let id: String
var name: String?
var members: [ConversationMember]
var createdBy: String?
var avatarFile: String?
var unreadCount: Int
var isFavorite: Bool
var lastMessageTime: Date?
var isGroup: Bool {
name != nil || members.count > 2
}
/// Display name: group name, or DM partner username
func displayName(currentUserId: String) -> String {
if let name = name, !name.isEmpty {
return name
}
// DM: show the other person's name
if let other = members.first(where: { $0.userId != currentUserId }) {
return other.username
}
return "Unknown"
}
/// DM partner user ID (nil for groups)
func dmPartnerId(currentUserId: String) -> String? {
guard !isGroup else { return nil }
return members.first(where: { $0.userId != currentUserId })?.userId
}
static func == (lhs: Conversation, rhs: Conversation) -> Bool {
lhs.id == rhs.id
&& lhs.name == rhs.name
&& lhs.members == rhs.members
&& lhs.avatarFile == rhs.avatarFile
&& lhs.unreadCount == rhs.unreadCount
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct ConversationMember: Identifiable, Equatable, Codable {
let userId: String
var username: String
var email: String
var id: String { userId }
}

View File

@@ -0,0 +1,69 @@
import Foundation
/// Key bundle for one device, used in X3DH
struct DeviceBundle {
let deviceId: String
let identityKey: Data // Ed25519 public key (32 bytes)
let spk: Data // X25519 public key (32 bytes)
let spkSignature: Data // Ed25519 signature (64 bytes)
let spkId: String
let opk: Data? // X25519 public key (32 bytes), optional
let opkId: String?
/// Parse from server response dictionary
/// Server uses: signed_prekey, signed_prekey_id, one_time_prekey, one_time_prekey_id (base64)
static func fromDict(_ dict: [String: Any], identityKey: Data? = nil) throws -> DeviceBundle {
guard let deviceId = dict["device_id"] as? String else {
throw ChatError.invalidData("Missing device_id")
}
// Identity key can be passed in (from parent) or in dict
let ik: Data
if let passedIk = identityKey {
ik = passedIk
} else if let ikB64 = dict["identity_key"] as? String,
let ikData = Data(base64Encoded: ikB64) {
ik = ikData
} else {
throw ChatError.invalidData("Missing identity_key")
}
// SPK - try both naming conventions, base64 encoded
let spkB64 = dict["signed_prekey"] as? String ?? dict["spk"] as? String
guard let spkB64 = spkB64,
let spk = Data(base64Encoded: spkB64) else {
throw ChatError.invalidData("Missing signed_prekey")
}
// SPK signature - base64 encoded
guard let spkSigB64 = dict["spk_signature"] as? String,
let spkSig = Data(base64Encoded: spkSigB64) else {
throw ChatError.invalidData("Missing spk_signature")
}
// SPK ID - try both naming conventions
let spkId = dict["signed_prekey_id"] as? String ?? dict["spk_id"] as? String
guard let spkId = spkId else {
throw ChatError.invalidData("Missing signed_prekey_id")
}
// OPK - optional, base64 encoded
var opk: Data?
var opkId: String?
let opkB64 = dict["one_time_prekey"] as? String ?? dict["opk"] as? String
if let opkB64 = opkB64, let opkData = Data(base64Encoded: opkB64) {
opk = opkData
opkId = dict["one_time_prekey_id"] as? String ?? dict["opk_id"] as? String
}
return DeviceBundle(
deviceId: deviceId,
identityKey: ik,
spk: spk,
spkSignature: spkSig,
spkId: spkId,
opk: opk,
opkId: opkId
)
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
struct Invitation: Identifiable {
let id: String // invitation id (from server) or conversationId
let conversationId: String
let conversationName: String
let invitedBy: String
let invitedByUsername: String
}

View File

@@ -0,0 +1,210 @@
import Foundation
struct MessageReaction: Equatable {
let userId: String
let reaction: String
let createdAt: Date
}
struct ForwardedFrom: Equatable {
let sender: String
let conversationId: String
let messageId: String
}
enum ReactionEmoji {
static let allowed = ["thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"]
static let display: [String: String] = [
"thumbsup": "👍", "heart": "❤️", "laugh": "😂",
"surprised": "😮", "sad": "😢", "thumbsdown": "👎",
]
}
struct Message: Identifiable, Equatable {
let id: String
let conversationId: String
let senderId: String
var senderUsername: String
let createdAt: Date
var text: String?
var replyTo: String?
var imageFileId: String?
var file: FileInfo?
var image: ImageInfo?
var isDeleted: Bool
var readBy: Set<String>
var reactions: [MessageReaction]
var forwardedFrom: ForwardedFrom?
var pinnedAt: Date?
var pinnedBy: String?
/// Whether this is a self-sent message
func isMine(currentUserId: String) -> Bool {
senderId == currentUserId
}
static func == (lhs: Message, rhs: Message) -> Bool {
lhs.id == rhs.id
}
}
struct FileInfo: Equatable, Codable {
let fileId: String
let aesKey: String // base64
let iv: String // base64
let filename: String
let size: Int
let mimeType: String
}
struct ImageInfo: Equatable {
let fileId: String
let aesKey: String // base64
let iv: String // base64
let thumbnail: String? // base64 JPEG thumbnail
let filename: String
let size: Int
}
// MARK: - Cache Dictionary Conversion
extension Message {
/// Convert to dictionary matching server JSON format for MessageCache storage
func toCacheDict() -> [String: Any] {
var dict: [String: Any] = [
"message_id": id,
"conversation_id": conversationId,
"sender_id": senderId,
"sender_username": senderUsername,
"created_at": DateParsing.format(createdAt),
"is_deleted": isDeleted,
]
if let text = text { dict["text"] = text }
if let replyTo = replyTo { dict["reply_to"] = replyTo }
if let imageFileId = imageFileId { dict["image_file_id"] = imageFileId }
if let file = file {
dict["file"] = [
"file_id": file.fileId,
"aes_key": file.aesKey,
"iv": file.iv,
"filename": file.filename,
"size": file.size,
"mime_type": file.mimeType,
] as [String: Any]
}
if let image = image {
var imgDict: [String: Any] = [
"file_id": image.fileId,
"aes_key": image.aesKey,
"iv": image.iv,
"filename": image.filename,
"size": image.size,
]
if let thumbnail = image.thumbnail { imgDict["thumbnail"] = thumbnail }
dict["image"] = imgDict
}
if !readBy.isEmpty { dict["read_by"] = Array(readBy) }
if !reactions.isEmpty {
dict["reactions"] = reactions.map {
["user_id": $0.userId, "reaction": $0.reaction,
"created_at": DateParsing.format($0.createdAt)] as [String: Any]
}
}
if let fwd = forwardedFrom {
dict["forwarded_from"] = ["sender": fwd.sender,
"conversation_id": fwd.conversationId,
"message_id": fwd.messageId] as [String: Any]
}
if let pinnedAt { dict["pinned_at"] = DateParsing.format(pinnedAt) }
if let pinnedBy { dict["pinned_by"] = pinnedBy }
return dict
}
/// Create Message from cache dictionary (server JSON format)
static func fromCacheDict(_ dict: [String: Any]) -> Message? {
guard let id = dict["message_id"] as? String,
let conversationId = dict["conversation_id"] as? String,
let senderId = dict["sender_id"] as? String,
let createdAtStr = dict["created_at"] as? String,
let createdAt = DateParsing.parse(createdAtStr) else {
return nil
}
let senderUsername = dict["sender_username"] as? String ?? ""
var file: FileInfo?
if let fileDict = dict["file"] as? [String: Any],
let fileId = fileDict["file_id"] as? String {
file = FileInfo(
fileId: fileId,
aesKey: fileDict["aes_key"] as? String ?? "",
iv: fileDict["iv"] as? String ?? "",
filename: fileDict["filename"] as? String ?? "",
size: fileDict["size"] as? Int ?? 0,
mimeType: fileDict["mime_type"] as? String ?? ""
)
}
var image: ImageInfo?
if let imgDict = dict["image"] as? [String: Any],
let imgFileId = imgDict["file_id"] as? String {
image = ImageInfo(
fileId: imgFileId,
aesKey: imgDict["aes_key"] as? String ?? "",
iv: imgDict["iv"] as? String ?? "",
thumbnail: imgDict["thumbnail"] as? String,
filename: imgDict["filename"] as? String ?? "image.jpg",
size: imgDict["size"] as? Int ?? 0
)
}
let readBy: Set<String>
if let readByArray = dict["read_by"] as? [String] {
readBy = Set(readByArray)
} else {
readBy = []
}
var reactions: [MessageReaction] = []
if let reactionsArr = dict["reactions"] as? [[String: Any]] {
reactions = reactionsArr.compactMap { r in
guard let userId = r["user_id"] as? String,
let reaction = r["reaction"] as? String else { return nil }
let createdAt = (r["created_at"] as? String).flatMap { DateParsing.parse($0) } ?? Date()
return MessageReaction(userId: userId, reaction: reaction, createdAt: createdAt)
}
}
var forwardedFrom: ForwardedFrom?
if let fwd = dict["forwarded_from"] as? [String: Any],
let sender = fwd["sender"] as? String {
forwardedFrom = ForwardedFrom(
sender: sender,
conversationId: fwd["conversation_id"] as? String ?? "",
messageId: fwd["message_id"] as? String ?? ""
)
}
let pinnedAt = (dict["pinned_at"] as? String).flatMap { DateParsing.parse($0) }
let pinnedBy = dict["pinned_by"] as? String
return Message(
id: id,
conversationId: conversationId,
senderId: senderId,
senderUsername: senderUsername,
createdAt: createdAt,
text: dict["text"] as? String,
replyTo: dict["reply_to"] as? String,
imageFileId: dict["image_file_id"] as? String,
file: file,
image: image,
isDeleted: dict["is_deleted"] as? Bool ?? false,
readBy: readBy,
reactions: reactions,
forwardedFrom: forwardedFrom,
pinnedAt: pinnedAt,
pinnedBy: pinnedBy
)
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
struct User: Identifiable, Equatable {
let id: String
var username: String
var email: String
var identityKey: Data? // Ed25519 public key (32 bytes)
}
struct UserProfile: Equatable {
var userId: String
var username: String?
var email: String?
var phone: String?
var phoneVisible: Bool
var location: String?
var locationVisible: Bool
var avatarFile: String?
}