Групповые чаты: sender name/avatar в ячейках, multi-typer typing, фикс скачивания фото/аватарок и verified badge
This commit is contained in:
@@ -12,12 +12,12 @@
|
||||
<key>RosettaLiveActivityWidget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>RosettaNotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
||||
@@ -176,7 +176,10 @@ enum MessageCrypto {
|
||||
var seen = Set<String>()
|
||||
return candidates.filter { seen.insert($0).inserted }
|
||||
}
|
||||
return [stored]
|
||||
// Group key or plain password — try hex(utf8Bytes) first (Desktop parity:
|
||||
// Desktop encrypts group attachments with Buffer.from(groupKey).toString('hex')).
|
||||
let hexVariant = Data(stored.utf8).map { String(format: "%02x", $0) }.joined()
|
||||
return hexVariant == stored ? [stored] : [hexVariant, stored]
|
||||
}
|
||||
|
||||
// MARK: - Android-Compatible UTF-8 Decoder
|
||||
|
||||
@@ -916,6 +916,19 @@ final class DatabaseManager {
|
||||
} catch { return 0 }
|
||||
}
|
||||
|
||||
/// Deletes sync cursor, forcing next sync to start from timestamp=0 (full re-sync).
|
||||
/// Used for one-time recovery of orphaned group keys.
|
||||
func resetSyncCursor(account: String) {
|
||||
do {
|
||||
try writeSync { db in
|
||||
try db.execute(
|
||||
sql: "DELETE FROM accounts_sync_times WHERE account = ?",
|
||||
arguments: [account]
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/// Save sync cursor. Monotonic only — never decreases.
|
||||
func saveSyncCursor(account: String, timestamp: Int64) {
|
||||
guard timestamp > 0 else { return }
|
||||
@@ -970,194 +983,4 @@ final class DatabaseManager {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group Repository (SQLite)
|
||||
|
||||
@MainActor
|
||||
final class GroupRepository {
|
||||
static let shared = GroupRepository()
|
||||
|
||||
private static let groupInvitePassword = "rosetta_group"
|
||||
private let db = DatabaseManager.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
struct GroupMetadata: Equatable {
|
||||
let title: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
private struct ParsedGroupInvite {
|
||||
let groupId: String
|
||||
let title: String
|
||||
let encryptKey: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
func isGroupDialog(_ value: String) -> Bool {
|
||||
DatabaseManager.isGroupDialogKey(value)
|
||||
}
|
||||
|
||||
func normalizeGroupId(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lower = trimmed.lowercased()
|
||||
if lower.hasPrefix("#group:") {
|
||||
return String(trimmed.dropFirst("#group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if lower.hasPrefix("group:") {
|
||||
return String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if lower.hasPrefix("conversation:") {
|
||||
return String(trimmed.dropFirst("conversation:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func toGroupDialogKey(_ value: String) -> String {
|
||||
let normalized = normalizeGroupId(value)
|
||||
guard !normalized.isEmpty else {
|
||||
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return "#group:\(normalized)"
|
||||
}
|
||||
|
||||
func groupKey(account: String, privateKeyHex: String, groupDialogKey: String) -> String? {
|
||||
let groupId = normalizeGroupId(groupDialogKey)
|
||||
guard !groupId.isEmpty else { return nil }
|
||||
|
||||
do {
|
||||
let storedKey = try db.read { db in
|
||||
try String.fetchOne(
|
||||
db,
|
||||
sql: """
|
||||
SELECT "key"
|
||||
FROM groups
|
||||
WHERE account = ? AND group_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
arguments: [account, groupId]
|
||||
)
|
||||
}
|
||||
guard let storedKey, !storedKey.isEmpty else { return nil }
|
||||
|
||||
if let decrypted = try? CryptoManager.shared.decryptWithPassword(storedKey, password: privateKeyHex),
|
||||
let plain = String(data: decrypted, encoding: .utf8),
|
||||
!plain.isEmpty {
|
||||
return plain
|
||||
}
|
||||
|
||||
// Backward compatibility: tolerate legacy plain stored keys.
|
||||
return storedKey
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func groupMetadata(account: String, groupDialogKey: String) -> GroupMetadata? {
|
||||
let groupId = normalizeGroupId(groupDialogKey)
|
||||
guard !groupId.isEmpty else { return nil }
|
||||
|
||||
do {
|
||||
return try db.read { db in
|
||||
guard let row = try Row.fetchOne(
|
||||
db,
|
||||
sql: """
|
||||
SELECT title, description
|
||||
FROM groups
|
||||
WHERE account = ? AND group_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
arguments: [account, groupId]
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return GroupMetadata(
|
||||
title: row["title"],
|
||||
description: row["description"]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func upsertFromGroupJoin(
|
||||
account: String,
|
||||
privateKeyHex: String,
|
||||
packet: PacketGroupJoin
|
||||
) -> String? {
|
||||
guard packet.status == .joined else { return nil }
|
||||
guard !packet.groupString.isEmpty else { return nil }
|
||||
|
||||
guard let decryptedInviteData = try? CryptoManager.shared.decryptWithPassword(
|
||||
packet.groupString,
|
||||
password: privateKeyHex
|
||||
), let inviteString = String(data: decryptedInviteData, encoding: .utf8),
|
||||
let parsed = parseGroupInviteString(inviteString)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let encryptedGroupKey = try? CryptoManager.shared.encryptWithPassword(
|
||||
Data(parsed.encryptKey.utf8),
|
||||
password: privateKeyHex
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try db.writeSync { db in
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT INTO groups (account, group_id, title, description, "key")
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account, group_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
"key" = excluded."key"
|
||||
""",
|
||||
arguments: [account, parsed.groupId, parsed.title, parsed.description, encryptedGroupKey]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
return toGroupDialogKey(parsed.groupId)
|
||||
}
|
||||
|
||||
private func parseGroupInviteString(_ inviteString: String) -> ParsedGroupInvite? {
|
||||
let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lower = trimmed.lowercased()
|
||||
|
||||
let encodedPayload: String
|
||||
if lower.hasPrefix("#group:") {
|
||||
encodedPayload = String(trimmed.dropFirst("#group:".count))
|
||||
} else if lower.hasPrefix("group:") {
|
||||
encodedPayload = String(trimmed.dropFirst("group:".count))
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
guard !encodedPayload.isEmpty else { return nil }
|
||||
|
||||
guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword(
|
||||
encodedPayload,
|
||||
password: Self.groupInvitePassword
|
||||
), let payload = String(data: decryptedPayload, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
|
||||
guard parts.count >= 3 else { return nil }
|
||||
|
||||
let groupId = normalizeGroupId(parts[0])
|
||||
guard !groupId.isEmpty else { return nil }
|
||||
|
||||
return ParsedGroupInvite(
|
||||
groupId: groupId,
|
||||
title: parts[1],
|
||||
encryptKey: parts[2],
|
||||
description: parts.dropFirst(3).joined(separator: ":")
|
||||
)
|
||||
}
|
||||
}
|
||||
// GroupRepository is in Core/Data/Repositories/GroupRepository.swift
|
||||
|
||||
@@ -56,13 +56,10 @@ struct Dialog: Identifiable, Codable, Equatable {
|
||||
// MARK: - Computed
|
||||
|
||||
var isSavedMessages: Bool { opponentKey == account }
|
||||
var isGroup: Bool { DatabaseManager.isGroupDialogKey(opponentKey) }
|
||||
|
||||
/// Client-side heuristic matching Android: badge shown if verified > 0 OR isRosettaOfficial.
|
||||
var isRosettaOfficial: Bool {
|
||||
opponentTitle.caseInsensitiveCompare("Rosetta") == .orderedSame ||
|
||||
opponentUsername.caseInsensitiveCompare("rosetta") == .orderedSame ||
|
||||
opponentTitle.caseInsensitiveCompare("freddy") == .orderedSame ||
|
||||
opponentUsername.caseInsensitiveCompare("freddy") == .orderedSame ||
|
||||
SystemAccounts.isSystemAccount(opponentKey)
|
||||
}
|
||||
|
||||
|
||||
365
Rosetta/Core/Data/Repositories/GroupRepository.swift
Normal file
365
Rosetta/Core/Data/Repositories/GroupRepository.swift
Normal file
@@ -0,0 +1,365 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
import os
|
||||
|
||||
// MARK: - Group Repository
|
||||
|
||||
/// Manages group chat metadata, invite strings, and encryption keys in SQLite.
|
||||
/// Extracted from DatabaseManager — all group-related DB operations live here.
|
||||
@MainActor
|
||||
final class GroupRepository {
|
||||
static let shared = GroupRepository()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "GroupRepo")
|
||||
private static let groupInvitePassword = "rosetta_group"
|
||||
private let db = DatabaseManager.shared
|
||||
|
||||
/// In-memory cache: ["{account}:{groupId}"] → decrypted group key.
|
||||
/// Prevents GRDB reentrancy crash when groupKey() is called from within a db.read transaction
|
||||
/// (e.g., MessageRepository.lastDecryptedMessage → decryptRecord → tryReDecrypt → groupKey).
|
||||
private var keyCache: [String: String] = [:]
|
||||
private var metadataCache: [String: GroupMetadata] = [:]
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Clears all in-memory caches. Call on logout/account switch.
|
||||
func clearCaches() {
|
||||
keyCache.removeAll()
|
||||
metadataCache.removeAll()
|
||||
}
|
||||
|
||||
private func cacheKey(account: String, groupId: String) -> String {
|
||||
"\(account):\(groupId)"
|
||||
}
|
||||
|
||||
// MARK: - Public Types
|
||||
|
||||
struct GroupMetadata: Equatable {
|
||||
let title: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
struct ParsedGroupInvite {
|
||||
let groupId: String
|
||||
let title: String
|
||||
let encryptKey: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
// MARK: - Normalization
|
||||
|
||||
func isGroupDialog(_ value: String) -> Bool {
|
||||
DatabaseManager.isGroupDialogKey(value)
|
||||
}
|
||||
|
||||
func normalizeGroupId(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lower = trimmed.lowercased()
|
||||
if lower.hasPrefix("#group:") {
|
||||
return String(trimmed.dropFirst("#group:".count))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if lower.hasPrefix("group:") {
|
||||
return String(trimmed.dropFirst("group:".count))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if lower.hasPrefix("conversation:") {
|
||||
return String(trimmed.dropFirst("conversation:".count))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func toGroupDialogKey(_ value: String) -> String {
|
||||
let normalized = normalizeGroupId(value)
|
||||
guard !normalized.isEmpty else {
|
||||
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return "#group:\(normalized)"
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func groupKey(account: String, privateKeyHex: String, groupDialogKey: String) -> String? {
|
||||
let groupId = normalizeGroupId(groupDialogKey)
|
||||
guard !groupId.isEmpty else { return nil }
|
||||
|
||||
// Check in-memory cache first (prevents GRDB reentrancy crash).
|
||||
let ck = cacheKey(account: account, groupId: groupId)
|
||||
if let cached = keyCache[ck] {
|
||||
return cached
|
||||
}
|
||||
|
||||
do {
|
||||
let storedKey = try db.read { db in
|
||||
try String.fetchOne(
|
||||
db,
|
||||
sql: """
|
||||
SELECT "key"
|
||||
FROM groups
|
||||
WHERE account = ? AND group_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
arguments: [account, groupId]
|
||||
)
|
||||
}
|
||||
guard let storedKey, !storedKey.isEmpty else { return nil }
|
||||
|
||||
if let decrypted = try? CryptoManager.shared.decryptWithPassword(
|
||||
storedKey, password: privateKeyHex
|
||||
),
|
||||
let plain = String(data: decrypted, encoding: .utf8),
|
||||
!plain.isEmpty {
|
||||
keyCache[ck] = plain
|
||||
return plain
|
||||
}
|
||||
|
||||
// Backward compatibility: tolerate legacy plain stored keys.
|
||||
keyCache[ck] = storedKey
|
||||
return storedKey
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func groupMetadata(account: String, groupDialogKey: String) -> GroupMetadata? {
|
||||
let groupId = normalizeGroupId(groupDialogKey)
|
||||
guard !groupId.isEmpty else { return nil }
|
||||
|
||||
let ck = cacheKey(account: account, groupId: groupId)
|
||||
if let cached = metadataCache[ck] {
|
||||
return cached
|
||||
}
|
||||
|
||||
do {
|
||||
let result: GroupMetadata? = try db.read { db in
|
||||
guard let row = try Row.fetchOne(
|
||||
db,
|
||||
sql: """
|
||||
SELECT title, description
|
||||
FROM groups
|
||||
WHERE account = ? AND group_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
arguments: [account, groupId]
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return GroupMetadata(
|
||||
title: row["title"],
|
||||
description: row["description"]
|
||||
)
|
||||
}
|
||||
if let result {
|
||||
metadataCache[ck] = result
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the string looks like a group invite (starts with `#group:` and can be parsed).
|
||||
func isValidInviteString(_ value: String) -> Bool {
|
||||
parseInviteString(value) != nil
|
||||
}
|
||||
|
||||
// MARK: - Invite String
|
||||
|
||||
/// Constructs a shareable invite string: `#group:<encrypted(groupId:title:key[:desc])>`
|
||||
/// Uses `encryptWithPasswordDesktopCompat` for cross-platform compatibility (Android/Desktop).
|
||||
func constructInviteString(
|
||||
groupId: String,
|
||||
title: String,
|
||||
encryptKey: String,
|
||||
description: String = ""
|
||||
) -> String? {
|
||||
let normalized = normalizeGroupId(groupId)
|
||||
guard !normalized.isEmpty, !title.isEmpty, !encryptKey.isEmpty else { return nil }
|
||||
|
||||
var payload = "\(normalized):\(title):\(encryptKey)"
|
||||
if !description.isEmpty {
|
||||
payload += ":\(description)"
|
||||
}
|
||||
|
||||
guard let encrypted = try? CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
Data(payload.utf8),
|
||||
password: Self.groupInvitePassword
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return "#group:\(encrypted)"
|
||||
}
|
||||
|
||||
/// Parses an invite string into its components.
|
||||
func parseInviteString(_ inviteString: String) -> ParsedGroupInvite? {
|
||||
let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lower = trimmed.lowercased()
|
||||
|
||||
let rawPayload: String
|
||||
if lower.hasPrefix("#group:") {
|
||||
rawPayload = String(trimmed.dropFirst("#group:".count))
|
||||
} else if lower.hasPrefix("group:") {
|
||||
rawPayload = String(trimmed.dropFirst("group:".count))
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
// Strip internal whitespace/newlines that may be introduced when copying
|
||||
// from a chat bubble (message text wraps across multiple lines).
|
||||
let encodedPayload = rawPayload.components(separatedBy: .whitespacesAndNewlines).joined()
|
||||
guard !encodedPayload.isEmpty else { return nil }
|
||||
|
||||
guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword(
|
||||
encodedPayload,
|
||||
password: Self.groupInvitePassword
|
||||
), let payload = String(data: decryptedPayload, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
|
||||
guard parts.count >= 3 else { return nil }
|
||||
|
||||
let groupId = normalizeGroupId(parts[0])
|
||||
guard !groupId.isEmpty else { return nil }
|
||||
|
||||
return ParsedGroupInvite(
|
||||
groupId: groupId,
|
||||
title: parts[1],
|
||||
encryptKey: parts[2],
|
||||
description: parts.dropFirst(3).joined(separator: ":")
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Persists group from a joined `PacketGroupJoin` (server-pushed or self-initiated).
|
||||
@discardableResult
|
||||
func upsertFromGroupJoin(
|
||||
account: String,
|
||||
privateKeyHex: String,
|
||||
packet: PacketGroupJoin
|
||||
) -> String? {
|
||||
guard packet.status == .joined else { return nil }
|
||||
guard !packet.groupString.isEmpty else { return nil }
|
||||
|
||||
guard let decryptedInviteData = try? CryptoManager.shared.decryptWithPassword(
|
||||
packet.groupString,
|
||||
password: privateKeyHex
|
||||
), let inviteString = String(data: decryptedInviteData, encoding: .utf8),
|
||||
let parsed = parseInviteString(inviteString)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return persistGroup(
|
||||
account: account,
|
||||
privateKeyHex: privateKeyHex,
|
||||
groupId: parsed.groupId,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
encryptKey: parsed.encryptKey
|
||||
)
|
||||
}
|
||||
|
||||
/// Persists a group directly from parsed invite components.
|
||||
@discardableResult
|
||||
func persistGroup(
|
||||
account: String,
|
||||
privateKeyHex: String,
|
||||
groupId: String,
|
||||
title: String,
|
||||
description: String,
|
||||
encryptKey: String
|
||||
) -> String? {
|
||||
guard let encryptedGroupKey = try? CryptoManager.shared.encryptWithPassword(
|
||||
Data(encryptKey.utf8),
|
||||
password: privateKeyHex
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try db.writeSync { db in
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT INTO groups (account, group_id, title, description, "key")
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account, group_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
"key" = excluded."key"
|
||||
""",
|
||||
arguments: [account, groupId, title, description, encryptedGroupKey]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
Self.logger.error("Failed to persist group \(groupId): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Populate in-memory caches so subsequent reads don't hit DB
|
||||
// (prevents GRDB reentrancy when called from within a transaction).
|
||||
let ck = cacheKey(account: account, groupId: groupId)
|
||||
keyCache[ck] = encryptKey
|
||||
metadataCache[ck] = GroupMetadata(title: title, description: description)
|
||||
|
||||
return toGroupDialogKey(groupId)
|
||||
}
|
||||
|
||||
/// Deletes a group and all its local messages/dialog.
|
||||
func deleteGroup(account: String, groupDialogKey: String) {
|
||||
let groupId = normalizeGroupId(groupDialogKey)
|
||||
guard !groupId.isEmpty else { return }
|
||||
let dialogKey = toGroupDialogKey(groupId)
|
||||
|
||||
// Clear caches first.
|
||||
let ck = cacheKey(account: account, groupId: groupId)
|
||||
keyCache.removeValue(forKey: ck)
|
||||
metadataCache.removeValue(forKey: ck)
|
||||
|
||||
do {
|
||||
try db.writeSync { db in
|
||||
try db.execute(
|
||||
sql: "DELETE FROM groups WHERE account = ? AND group_id = ?",
|
||||
arguments: [account, groupId]
|
||||
)
|
||||
try db.execute(
|
||||
sql: "DELETE FROM messages WHERE account = ? AND dialog_key = ?",
|
||||
arguments: [account, dialogKey]
|
||||
)
|
||||
try db.execute(
|
||||
sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?",
|
||||
arguments: [account, dialogKey]
|
||||
)
|
||||
}
|
||||
Self.logger.info("Deleted group \(groupId) and its local data")
|
||||
} catch {
|
||||
Self.logger.error("Failed to delete group \(groupId): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all group IDs for the given account.
|
||||
func allGroupIds(account: String) -> [String] {
|
||||
do {
|
||||
return try db.read { db in
|
||||
try String.fetchAll(
|
||||
db,
|
||||
sql: "SELECT group_id FROM groups WHERE account = ?",
|
||||
arguments: [account]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key Generation (Android parity: 32 random bytes → hex)
|
||||
|
||||
/// Generates a random 32-byte group encryption key as a 64-char hex string.
|
||||
/// Matches Android `GroupRepository.generateGroupKey()`.
|
||||
func generateGroupKey() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ final class MessageRepository: ObservableObject {
|
||||
|
||||
/// In-memory cache of messages for active dialogs. UI binds to this.
|
||||
@Published private(set) var messagesByDialog: [String: [ChatMessage]] = [:]
|
||||
@Published private(set) var typingDialogs: Set<String> = []
|
||||
/// dialogKey → set of senderPublicKeys for active typing indicators.
|
||||
@Published private(set) var typingDialogs: [String: Set<String>] = [:]
|
||||
|
||||
private var activeDialogs: Set<String> = []
|
||||
/// Dialogs that are currently eligible for interactive read:
|
||||
/// screen is visible and list is at the bottom (Telegram-like behavior).
|
||||
private var readEligibleDialogs: Set<String> = []
|
||||
/// Per-sender typing reset timers. Key: "dialogKey::senderKey"
|
||||
private var typingResetTasks: [String: Task<Void, Never>] = [:]
|
||||
private var currentAccount: String = ""
|
||||
|
||||
@@ -250,9 +252,8 @@ final class MessageRepository: ObservableObject {
|
||||
} else {
|
||||
activeDialogs.remove(dialogKey)
|
||||
readEligibleDialogs.remove(dialogKey)
|
||||
typingDialogs.remove(dialogKey)
|
||||
typingResetTasks[dialogKey]?.cancel()
|
||||
typingResetTasks[dialogKey] = nil
|
||||
typingDialogs.removeValue(forKey: dialogKey)
|
||||
cancelTypingTimers(for: dialogKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,21 +547,29 @@ final class MessageRepository: ObservableObject {
|
||||
|
||||
// MARK: - Typing
|
||||
|
||||
func markTyping(from opponentKey: String) {
|
||||
if !typingDialogs.contains(opponentKey) {
|
||||
typingDialogs.insert(opponentKey)
|
||||
func markTyping(from dialogKey: String, senderKey: String) {
|
||||
var current = typingDialogs[dialogKey] ?? []
|
||||
if !current.contains(senderKey) {
|
||||
current.insert(senderKey)
|
||||
typingDialogs[dialogKey] = current
|
||||
}
|
||||
typingResetTasks[opponentKey]?.cancel()
|
||||
typingResetTasks[opponentKey] = Task { @MainActor [weak self] in
|
||||
// Per-sender timeout (Desktop parity: each typer has own 3s timer)
|
||||
let timerKey = "\(dialogKey)::\(senderKey)"
|
||||
typingResetTasks[timerKey]?.cancel()
|
||||
typingResetTasks[timerKey] = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.typingDialogs.remove(opponentKey)
|
||||
self.typingResetTasks[opponentKey] = nil
|
||||
self.typingDialogs[dialogKey]?.remove(senderKey)
|
||||
if self.typingDialogs[dialogKey]?.isEmpty == true {
|
||||
self.typingDialogs.removeValue(forKey: dialogKey)
|
||||
}
|
||||
self.typingResetTasks.removeValue(forKey: timerKey)
|
||||
}
|
||||
}
|
||||
|
||||
func isTyping(dialogKey: String) -> Bool {
|
||||
typingDialogs.contains(dialogKey)
|
||||
guard let set = typingDialogs[dialogKey] else { return false }
|
||||
return !set.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
@@ -580,9 +589,17 @@ final class MessageRepository: ObservableObject {
|
||||
messagesByDialog.removeValue(forKey: dialogKey)
|
||||
activeDialogs.remove(dialogKey)
|
||||
readEligibleDialogs.remove(dialogKey)
|
||||
typingDialogs.remove(dialogKey)
|
||||
typingResetTasks[dialogKey]?.cancel()
|
||||
typingResetTasks[dialogKey] = nil
|
||||
typingDialogs.removeValue(forKey: dialogKey)
|
||||
cancelTypingTimers(for: dialogKey)
|
||||
}
|
||||
|
||||
/// Cancel all per-sender typing timers for a dialog.
|
||||
private func cancelTypingTimers(for dialogKey: String) {
|
||||
let prefix = "\(dialogKey)::"
|
||||
for (key, task) in typingResetTasks where key.hasPrefix(prefix) {
|
||||
task.cancel()
|
||||
typingResetTasks.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteMessage(id: String) {
|
||||
@@ -738,19 +755,23 @@ final class MessageRepository: ObservableObject {
|
||||
/// Used by DialogRepository.updateDialogFromMessages() to set lastMessage text.
|
||||
func lastDecryptedMessage(account: String, opponentKey: String) -> ChatMessage? {
|
||||
let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: opponentKey)
|
||||
// Fetch raw record inside db.read, then decrypt OUTSIDE the transaction.
|
||||
// decryptRecord → tryReDecrypt → GroupRepository.groupKey() opens its own db.read,
|
||||
// which would crash with "Database methods are not reentrant" if still inside a transaction.
|
||||
let record: MessageRecord?
|
||||
do {
|
||||
return try db.read { db in
|
||||
guard let record = try MessageRecord
|
||||
record = try db.read { db in
|
||||
try MessageRecord
|
||||
.filter(
|
||||
MessageRecord.Columns.account == account &&
|
||||
MessageRecord.Columns.dialogKey == dialogKey
|
||||
)
|
||||
.order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc)
|
||||
.fetchOne(db)
|
||||
else { return nil }
|
||||
return decryptRecord(record)
|
||||
}
|
||||
} catch { return nil }
|
||||
guard let record else { return nil }
|
||||
return decryptRecord(record)
|
||||
}
|
||||
|
||||
// MARK: - Stress Test (Debug only)
|
||||
@@ -995,6 +1016,49 @@ final class MessageRepository: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Batch Re-Decrypt (Group Key Recovery)
|
||||
|
||||
/// Re-decrypts all messages in a group dialog that have empty text but non-empty content.
|
||||
/// Called when a group key becomes available AFTER messages were already stored without it.
|
||||
func reDecryptGroupMessages(
|
||||
account: String,
|
||||
groupDialogKey: String,
|
||||
groupKey: String,
|
||||
privateKeyHex: String
|
||||
) {
|
||||
guard !groupKey.isEmpty, !privateKeyHex.isEmpty else { return }
|
||||
let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: groupDialogKey)
|
||||
|
||||
let records: [MessageRecord]
|
||||
do {
|
||||
records = try db.read { db in
|
||||
try MessageRecord
|
||||
.filter(
|
||||
MessageRecord.Columns.account == account &&
|
||||
MessageRecord.Columns.dialogKey == dialogKey &&
|
||||
MessageRecord.Columns.content != ""
|
||||
)
|
||||
.fetchAll(db)
|
||||
}
|
||||
} catch { return }
|
||||
|
||||
var updated = 0
|
||||
for record in records {
|
||||
// Try decrypt with group key.
|
||||
guard let data = try? CryptoManager.shared.decryptWithPassword(
|
||||
record.content, password: groupKey
|
||||
), let text = String(data: data, encoding: .utf8), !text.isEmpty else {
|
||||
continue
|
||||
}
|
||||
persistReDecryptedText(text, forMessageId: record.messageId, privateKey: privateKeyHex)
|
||||
updated += 1
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
refreshDialogCache(for: groupDialogKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Android Parity: safePlainMessageFallback
|
||||
|
||||
/// Android parity: `safePlainMessageFallback()` in `ChatViewModel.kt` lines 1338-1349.
|
||||
|
||||
@@ -13,7 +13,7 @@ struct MessageCellLayout: Sendable {
|
||||
|
||||
// MARK: - Cell
|
||||
|
||||
let totalHeight: CGFloat
|
||||
var totalHeight: CGFloat
|
||||
let groupGap: CGFloat
|
||||
let isOutgoing: Bool
|
||||
let position: BubblePosition
|
||||
@@ -73,6 +73,13 @@ struct MessageCellLayout: Sendable {
|
||||
let dateHeaderText: String
|
||||
let dateHeaderHeight: CGFloat
|
||||
|
||||
// MARK: - Group Sender (optional)
|
||||
|
||||
var showsSenderName: Bool // true for .top/.single incoming in group
|
||||
var showsSenderAvatar: Bool // true for .bottom/.single incoming in group
|
||||
var senderName: String
|
||||
var senderKey: String
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum MessageType: Sendable {
|
||||
@@ -612,7 +619,11 @@ extension MessageCellLayout {
|
||||
forwardNameFrame: fwdNameFrame,
|
||||
showsDateHeader: config.showsDateHeader,
|
||||
dateHeaderText: config.dateHeaderText,
|
||||
dateHeaderHeight: dateHeaderH
|
||||
dateHeaderHeight: dateHeaderH,
|
||||
showsSenderName: false,
|
||||
showsSenderAvatar: false,
|
||||
senderName: "",
|
||||
senderKey: ""
|
||||
)
|
||||
return (layout, cachedTextLayout)
|
||||
}
|
||||
@@ -778,6 +789,11 @@ extension MessageCellLayout {
|
||||
return false
|
||||
}
|
||||
|
||||
// Group chats: different senders NEVER merge (Telegram parity).
|
||||
if message.fromPublicKey != neighbor.fromPublicKey {
|
||||
return false
|
||||
}
|
||||
|
||||
// Keep failed messages visually isolated (external failed indicator behavior).
|
||||
if message.deliveryStatus == .error || neighbor.deliveryStatus == .error {
|
||||
return false
|
||||
@@ -797,17 +813,12 @@ extension MessageCellLayout {
|
||||
|
||||
let currentKind = groupingKind(for: message, displayText: currentDisplayText)
|
||||
let neighborKind = groupingKind(for: neighbor, displayText: neighborDisplayText)
|
||||
guard currentKind == neighborKind else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Telegram-like grouping by semantic kind (except forwarded-empty blocks).
|
||||
switch currentKind {
|
||||
case .text, .media, .file:
|
||||
return true
|
||||
case .forward:
|
||||
// Forwards never merge (Telegram parity). All other kinds merge freely.
|
||||
if currentKind == .forward || neighborKind == .forward {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,7 +834,8 @@ extension MessageCellLayout {
|
||||
maxBubbleWidth: CGFloat,
|
||||
currentPublicKey: String,
|
||||
opponentPublicKey: String,
|
||||
opponentTitle: String
|
||||
opponentTitle: String,
|
||||
isGroupChat: Bool = false
|
||||
) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) {
|
||||
var result: [String: MessageCellLayout] = [:]
|
||||
var textResult: [String: CoreTextTextLayout] = [:]
|
||||
@@ -952,7 +964,24 @@ extension MessageCellLayout {
|
||||
dateHeaderText: dateHeaderText
|
||||
)
|
||||
|
||||
let (layout, textLayout) = calculate(config: config)
|
||||
var (layout, textLayout) = calculate(config: config)
|
||||
|
||||
// Group sender info: name on first in run, avatar on last in run.
|
||||
if isGroupChat && !isOutgoing {
|
||||
layout.senderKey = message.fromPublicKey
|
||||
let resolvedName = DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle
|
||||
?? String(message.fromPublicKey.prefix(8))
|
||||
layout.senderName = resolvedName
|
||||
// .top or .single = first message in sender run → show name
|
||||
layout.showsSenderName = (position == .top || position == .single)
|
||||
// .bottom or .single = last message in sender run → show avatar
|
||||
layout.showsSenderAvatar = (position == .bottom || position == .single)
|
||||
// Add height for sender name so cells don't overlap.
|
||||
if layout.showsSenderName {
|
||||
layout.totalHeight += 20
|
||||
}
|
||||
}
|
||||
|
||||
result[message.id] = layout
|
||||
if let textLayout { textResult[message.id] = textLayout }
|
||||
}
|
||||
|
||||
129
Rosetta/Core/Network/Protocol/PacketAwaiter.swift
Normal file
129
Rosetta/Core/Network/Protocol/PacketAwaiter.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Packet Awaiter
|
||||
|
||||
/// Async/await wrapper for request-response packet exchanges.
|
||||
/// Sends a packet and waits for a typed response matching a predicate, with timeout.
|
||||
///
|
||||
/// Uses ProtocolManager's one-shot handler mechanism. Thread-safe: handlers fire on
|
||||
/// URLSession delegate queue, continuation resumed via `@Sendable`.
|
||||
enum PacketAwaiter {
|
||||
|
||||
enum AwaitError: Error, LocalizedError {
|
||||
case timeout
|
||||
case notConnected
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .timeout: return "Server did not respond in time"
|
||||
case .notConnected: return "Not connected to server"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `outgoing` packet and waits for a response of type `T` matching `predicate`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - outgoing: The packet to send.
|
||||
/// - responsePacketId: The packet ID of the expected response (e.g., `0x11` for PacketCreateGroup).
|
||||
/// - timeout: Maximum wait time in seconds (default 15).
|
||||
/// - predicate: Filter to match the correct response (e.g., matching groupId).
|
||||
/// - Returns: The matched response packet.
|
||||
@MainActor
|
||||
static func send<T: Packet>(
|
||||
_ outgoing: some Packet,
|
||||
awaitResponse responsePacketId: Int,
|
||||
timeout: TimeInterval = 15,
|
||||
where predicate: @escaping @Sendable (T) -> Bool = { _ in true }
|
||||
) async throws -> T {
|
||||
let proto = ProtocolManager.shared
|
||||
guard proto.connectionState == .authenticated else {
|
||||
throw AwaitError.notConnected
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
// Guard against double-resume (timeout + response race).
|
||||
let resumed = AtomicFlag()
|
||||
var handlerId: UUID?
|
||||
var timeoutTask: Task<Void, Never>?
|
||||
|
||||
let id = proto.addGroupOneShotHandler(packetId: responsePacketId) { rawPacket in
|
||||
guard let response = rawPacket as? T else { return false }
|
||||
guard predicate(response) else { return false }
|
||||
|
||||
// Matched — consume and resume.
|
||||
if resumed.setIfFalse() {
|
||||
timeoutTask?.cancel()
|
||||
continuation.resume(returning: response)
|
||||
}
|
||||
return true
|
||||
}
|
||||
handlerId = id
|
||||
|
||||
proto.sendPacket(outgoing)
|
||||
|
||||
// Timeout fallback.
|
||||
timeoutTask = Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
if resumed.setIfFalse() {
|
||||
if let hid = handlerId {
|
||||
proto.removeGroupOneShotHandler(hid)
|
||||
}
|
||||
continuation.resume(throwing: AwaitError.timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for an incoming packet of type `T` without sending anything first.
|
||||
@MainActor
|
||||
static func awaitIncoming<T: Packet>(
|
||||
packetId: Int,
|
||||
timeout: TimeInterval = 15,
|
||||
where predicate: @escaping @Sendable (T) -> Bool = { _ in true }
|
||||
) async throws -> T {
|
||||
let proto = ProtocolManager.shared
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let resumed = AtomicFlag()
|
||||
var handlerId: UUID?
|
||||
var timeoutTask: Task<Void, Never>?
|
||||
|
||||
let id = proto.addGroupOneShotHandler(packetId: packetId) { rawPacket in
|
||||
guard let response = rawPacket as? T else { return false }
|
||||
guard predicate(response) else { return false }
|
||||
|
||||
if resumed.setIfFalse() {
|
||||
timeoutTask?.cancel()
|
||||
continuation.resume(returning: response)
|
||||
}
|
||||
return true
|
||||
}
|
||||
handlerId = id
|
||||
|
||||
timeoutTask = Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
if resumed.setIfFalse() {
|
||||
if let hid = handlerId {
|
||||
proto.removeGroupOneShotHandler(hid)
|
||||
}
|
||||
continuation.resume(throwing: AwaitError.timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AtomicFlag (lock-free double-resume guard)
|
||||
|
||||
/// Simple atomic boolean flag for preventing double continuation resume.
|
||||
private final class AtomicFlag: @unchecked Sendable {
|
||||
private var _value: Int32 = 0
|
||||
|
||||
/// Sets the flag to true. Returns `true` if it was previously false (first caller wins).
|
||||
func setIfFalse() -> Bool {
|
||||
OSAtomicCompareAndSwap32(0, 1, &_value)
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,22 @@ struct PacketWebRTC: Packet {
|
||||
|
||||
var signalType: WebRTCSignalType = .offer
|
||||
var sdpOrCandidate: String = ""
|
||||
/// Sender's public key — server validates via DeviceService (no session auth needed).
|
||||
var publicKey: String = ""
|
||||
/// Sender's device ID — server checks publicKey↔deviceId binding.
|
||||
var deviceId: String = ""
|
||||
|
||||
func write(to stream: Stream) {
|
||||
stream.writeInt8(signalType.rawValue)
|
||||
stream.writeString(sdpOrCandidate)
|
||||
stream.writeString(publicKey)
|
||||
stream.writeString(deviceId)
|
||||
}
|
||||
|
||||
mutating func read(from stream: Stream) {
|
||||
signalType = WebRTCSignalType(rawValue: stream.readInt8()) ?? .offer
|
||||
sdpOrCandidate = stream.readString()
|
||||
publicKey = stream.readString()
|
||||
deviceId = stream.readString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
private let signalPeerHandlersLock = NSLock()
|
||||
private let webRTCHandlersLock = NSLock()
|
||||
private let iceServersHandlersLock = NSLock()
|
||||
private let groupOneShotLock = NSLock()
|
||||
private let packetQueueLock = NSLock()
|
||||
private let searchRouter = SearchPacketRouter()
|
||||
private var resultHandlers: [UUID: (PacketResult) -> Void] = [:]
|
||||
@@ -95,6 +96,10 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
private var webRTCHandlers: [UUID: (PacketWebRTC) -> Void] = [:]
|
||||
private var iceServersHandlers: [UUID: (PacketIceServers) -> Void] = [:]
|
||||
|
||||
/// Generic one-shot handlers for group packets. Key: (packetId, handlerId).
|
||||
/// Handler returns `true` if it consumed the packet (auto-removed).
|
||||
private var groupOneShotHandlers: [UUID: (packetId: Int, handler: (any Packet) -> Bool)] = [:]
|
||||
|
||||
/// Background task to keep WebSocket alive during brief background periods (active call).
|
||||
/// iOS gives ~30s; enough for the call to survive app switching / notification interactions.
|
||||
private var callBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
||||
@@ -140,10 +145,13 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
return
|
||||
}
|
||||
case .connecting:
|
||||
if client.isConnecting {
|
||||
// Always skip if already in connecting state. Previous code only
|
||||
// checked client.isConnecting which has a race gap — between
|
||||
// setting connectionState=.connecting and client.connect() setting
|
||||
// isConnecting=true, a second call would slip through.
|
||||
// This caused 3 parallel WebSocket connections (3x every packet).
|
||||
Self.logger.info("Connect already in progress, skipping duplicate connect()")
|
||||
return
|
||||
}
|
||||
case .disconnected:
|
||||
break
|
||||
}
|
||||
@@ -189,13 +197,19 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
func forceReconnectOnForeground() {
|
||||
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
|
||||
|
||||
// During an active call the WebSocket may still be alive (background task
|
||||
// keeps the process running for ~30s). Tearing it down would break signaling
|
||||
// and trigger server re-delivery of .call — causing endCallBecauseBusy.
|
||||
// If the connection is authenticated, trust it and skip reconnect.
|
||||
// During an active call, WebRTC media flows via DTLS/SRTP (not WebSocket).
|
||||
// Tearing down the socket would trigger server re-delivery of .call and
|
||||
// cause unnecessary signaling disruption (endCallBecauseBusy).
|
||||
// For .active phase: skip entirely — WS is only needed for endCall signal,
|
||||
// which will work after natural reconnect or ICE timeout ends the call.
|
||||
// For other call phases: skip only if WS is authenticated (still alive).
|
||||
if CallManager.shared.uiState.phase == .active {
|
||||
Self.logger.info("⚡ Foreground reconnect skipped — call active, media via DTLS")
|
||||
return
|
||||
}
|
||||
if CallManager.shared.uiState.phase != .idle,
|
||||
connectionState == .authenticated {
|
||||
Self.logger.info("⚡ Foreground reconnect skipped — active call, WS authenticated")
|
||||
Self.logger.info("⚡ Foreground reconnect skipped — call in progress, WS authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -205,7 +219,8 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
case .handshaking, .deviceVerificationRequired:
|
||||
return
|
||||
case .connecting:
|
||||
if client.isConnecting { return }
|
||||
// Same fix as connect() — unconditional return to prevent triple connections
|
||||
return
|
||||
case .authenticated, .connected, .disconnected:
|
||||
break // Always reconnect — .authenticated/.connected may be zombie on iOS
|
||||
}
|
||||
@@ -229,6 +244,10 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
func beginCallBackgroundTask() {
|
||||
guard callBackgroundTask == .invalid else { return }
|
||||
callBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "RosettaCall") { [weak self] in
|
||||
// Don't end the call here — CallKit keeps the process alive for active calls.
|
||||
// This background task only buys time for WebSocket reconnection.
|
||||
// Killing the call on expiry was causing premature call termination
|
||||
// during keyExchange phase (~30s before Desktop could respond).
|
||||
self?.endCallBackgroundTask()
|
||||
}
|
||||
Self.logger.info("📞 Background task started for call")
|
||||
@@ -252,8 +271,8 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
case .authenticated, .handshaking, .deviceVerificationRequired, .connected:
|
||||
return
|
||||
case .connecting:
|
||||
// Android parity: `(CONNECTING && isConnecting)` — skip if connect() is in progress.
|
||||
if client.isConnecting { return }
|
||||
// Unconditional return — prevent duplicate connections (same fix as connect())
|
||||
return
|
||||
case .disconnected:
|
||||
break
|
||||
}
|
||||
@@ -352,6 +371,8 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
var packet = PacketWebRTC()
|
||||
packet.signalType = signalType
|
||||
packet.sdpOrCandidate = sdpOrCandidate
|
||||
packet.publicKey = SessionManager.shared.currentPublicKey
|
||||
packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
|
||||
sendPacket(packet)
|
||||
}
|
||||
|
||||
@@ -452,6 +473,47 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
resultHandlersLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Group One-Shot Handlers
|
||||
|
||||
/// Registers a one-shot handler for a specific packet type.
|
||||
/// Handler receives the packet and returns `true` to consume (auto-remove), `false` to keep.
|
||||
@discardableResult
|
||||
func addGroupOneShotHandler(packetId: Int, handler: @escaping (any Packet) -> Bool) -> UUID {
|
||||
let id = UUID()
|
||||
groupOneShotLock.lock()
|
||||
groupOneShotHandlers[id] = (packetId, handler)
|
||||
groupOneShotLock.unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func removeGroupOneShotHandler(_ id: UUID) {
|
||||
groupOneShotLock.lock()
|
||||
groupOneShotHandlers.removeValue(forKey: id)
|
||||
groupOneShotLock.unlock()
|
||||
}
|
||||
|
||||
/// Called from `routeIncomingPacket` — dispatches to matching one-shot handlers.
|
||||
private func notifyGroupOneShotHandlers(packetId: Int, packet: any Packet) {
|
||||
groupOneShotLock.lock()
|
||||
let matching = groupOneShotHandlers.filter { $0.value.packetId == packetId }
|
||||
groupOneShotLock.unlock()
|
||||
|
||||
var consumed: [UUID] = []
|
||||
for (id, entry) in matching {
|
||||
if entry.handler(packet) {
|
||||
consumed.append(id)
|
||||
}
|
||||
}
|
||||
|
||||
if !consumed.isEmpty {
|
||||
groupOneShotLock.lock()
|
||||
for id in consumed {
|
||||
groupOneShotHandlers.removeValue(forKey: id)
|
||||
}
|
||||
groupOneShotLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Setup
|
||||
|
||||
private func setupClientCallbacks() {
|
||||
@@ -668,26 +730,32 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
case 0x11:
|
||||
if let p = packet as? PacketCreateGroup {
|
||||
onCreateGroupReceived?(p)
|
||||
notifyGroupOneShotHandlers(packetId: 0x11, packet: p)
|
||||
}
|
||||
case 0x12:
|
||||
if let p = packet as? PacketGroupInfo {
|
||||
onGroupInfoReceived?(p)
|
||||
notifyGroupOneShotHandlers(packetId: 0x12, packet: p)
|
||||
}
|
||||
case 0x13:
|
||||
if let p = packet as? PacketGroupInviteInfo {
|
||||
onGroupInviteInfoReceived?(p)
|
||||
notifyGroupOneShotHandlers(packetId: 0x13, packet: p)
|
||||
}
|
||||
case 0x14:
|
||||
if let p = packet as? PacketGroupJoin {
|
||||
onGroupJoinReceived?(p)
|
||||
notifyGroupOneShotHandlers(packetId: 0x14, packet: p)
|
||||
}
|
||||
case 0x15:
|
||||
if let p = packet as? PacketGroupLeave {
|
||||
onGroupLeaveReceived?(p)
|
||||
notifyGroupOneShotHandlers(packetId: 0x15, packet: p)
|
||||
}
|
||||
case 0x16:
|
||||
if let p = packet as? PacketGroupBan {
|
||||
onGroupBanReceived?(p)
|
||||
notifyGroupOneShotHandlers(packetId: 0x16, packet: p)
|
||||
}
|
||||
case 0x0F:
|
||||
if let p = packet as? PacketRequestTransport {
|
||||
|
||||
@@ -216,7 +216,11 @@ final class CallKitManager: NSObject {
|
||||
// MARK: - End Call
|
||||
|
||||
func endCall() {
|
||||
guard let uuid = currentCallUUID else { return }
|
||||
guard let uuid = currentCallUUID else {
|
||||
Self.logger.notice("CallKit.endCall: no UUID — skipping")
|
||||
return
|
||||
}
|
||||
Self.logger.notice("CallKit.endCall uuid=\(uuid.uuidString.prefix(8), privacy: .public)")
|
||||
currentCallUUID = nil
|
||||
uuidLock.lock()
|
||||
_pendingCallUUID = nil
|
||||
@@ -233,6 +237,7 @@ final class CallKitManager: NSObject {
|
||||
|
||||
func reportCallEndedByRemote(reason: CXCallEndedReason = .remoteEnded) {
|
||||
guard let uuid = currentCallUUID else { return }
|
||||
Self.logger.notice("CallKit.reportCallEndedByRemote reason=\(reason.rawValue, privacy: .public) uuid=\(uuid.uuidString.prefix(8), privacy: .public)")
|
||||
currentCallUUID = nil
|
||||
uuidLock.lock()
|
||||
_pendingCallUUID = nil
|
||||
@@ -322,15 +327,26 @@ extension CallKitManager: CXProviderDelegate {
|
||||
// use whatever category is currently set. Without this, it may use
|
||||
// .soloAmbient (default) instead of .playAndRecord → silent audio.
|
||||
rtcSession.lockForConfiguration()
|
||||
try? rtcSession.setCategory(
|
||||
do {
|
||||
try rtcSession.setCategory(
|
||||
.playAndRecord, mode: .voiceChat,
|
||||
options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker]
|
||||
)
|
||||
} catch {
|
||||
Self.logger.error("Failed to set audio category: \(error.localizedDescription)")
|
||||
}
|
||||
rtcSession.unlockForConfiguration()
|
||||
// 3. NOW enable audio — ADM will init with correct .playAndRecord category.
|
||||
rtcSession.isAudioEnabled = true
|
||||
// Guard: CallKit may fire didActivate twice (observed in logs).
|
||||
// Second call is harmless for WebRTC but re-running onAudioSessionActivated
|
||||
// wastes cycles and logs confusing duplicate entries.
|
||||
Task { @MainActor in
|
||||
if !CallManager.shared.audioSessionActivated {
|
||||
await CallManager.shared.onAudioSessionActivated()
|
||||
} else {
|
||||
callLogger.info("[Call] didActivate: skipping duplicate (already activated)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,5 +355,8 @@ extension CallKitManager: CXProviderDelegate {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.audioSessionDidDeactivate(audioSession)
|
||||
rtcSession.isAudioEnabled = false
|
||||
Task { @MainActor in
|
||||
CallManager.shared.didReceiveCallKitDeactivation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,11 @@ extension CallManager {
|
||||
bufferedRemoteCandidates.append(candidate)
|
||||
return
|
||||
}
|
||||
try? await peerConnection.add(candidate)
|
||||
do {
|
||||
try await peerConnection.add(candidate)
|
||||
} catch {
|
||||
callLogger.warning("[Call] Failed to add ICE candidate: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +81,7 @@ extension CallManager {
|
||||
/// double-async nesting (Task inside Task) that causes race conditions.
|
||||
func onAudioSessionActivated() async {
|
||||
audioSessionActivated = true
|
||||
callLogger.info("[Call] didActivate: phase=\(self.uiState.phase.rawValue, privacy: .public) pendingWebRtcSetup=\(self.pendingWebRtcSetup.description, privacy: .public)")
|
||||
callLogger.notice("[Call] didActivate: phase=\(self.uiState.phase.rawValue, privacy: .public) pendingWebRtcSetup=\(self.pendingWebRtcSetup.description, privacy: .public)")
|
||||
guard uiState.phase != .idle else { return }
|
||||
|
||||
// Flush deferred WebRTC setup — .createRoom arrived before didActivate.
|
||||
@@ -105,6 +109,11 @@ extension CallManager {
|
||||
do {
|
||||
let peerConnection = try ensurePeerConnection()
|
||||
applySenderCryptorIfPossible()
|
||||
// Apply audio routing + enable track NOW — didActivate may have already
|
||||
// fired before createRoom arrived, so its applyAudioOutputRouting was a no-op
|
||||
// (no track existed yet). Without this, audio is silent on both sides.
|
||||
applyAudioOutputRouting()
|
||||
localAudioTrack?.isEnabled = !uiState.isMuted
|
||||
|
||||
let offer = try await createOffer(on: peerConnection)
|
||||
try await setLocalDescription(offer, on: peerConnection)
|
||||
@@ -151,7 +160,7 @@ extension CallManager {
|
||||
pendingCallKitAccept = false
|
||||
defer { isFinishingCall = false }
|
||||
|
||||
callLogger.info("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)")
|
||||
callLogger.notice("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)")
|
||||
|
||||
let snapshot = uiState
|
||||
|
||||
@@ -244,6 +253,7 @@ extension CallManager {
|
||||
lastPeerSharedPublicHex = ""
|
||||
audioSessionActivated = false
|
||||
pendingWebRtcSetup = false
|
||||
didReceiveCallKitDeactivation = false
|
||||
|
||||
var finalState = CallUiState()
|
||||
if let reason, !reason.isEmpty {
|
||||
@@ -284,7 +294,10 @@ extension CallManager {
|
||||
func applySenderCryptorIfPossible() {
|
||||
guard let sharedKey, sharedKey.count >= CallMediaCrypto.keyLength else { return }
|
||||
guard let localAudioSender else { return }
|
||||
_ = WebRTCFrameCryptorBridge.attach(localAudioSender, sharedKey: sharedKey)
|
||||
let attached = WebRTCFrameCryptorBridge.attach(localAudioSender, sharedKey: sharedKey)
|
||||
if !attached {
|
||||
callLogger.error("[Call] E2EE: FAILED to attach sender encryptor — audio may be unencrypted")
|
||||
}
|
||||
}
|
||||
|
||||
func startDurationTimerIfNeeded() {
|
||||
@@ -347,32 +360,20 @@ extension CallManager {
|
||||
return connection
|
||||
}
|
||||
|
||||
func configureAudioSession() throws {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
try rtcSession.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker]
|
||||
)
|
||||
// Do NOT call setActive(true) — session is already activated by CallKit
|
||||
// via audioSessionDidActivate(). Double activation increments WebRTC's
|
||||
// internal activation count, causing deactivateAudioSession() to decrement
|
||||
// to 1 instead of 0 — AVAudioSession never actually deactivates.
|
||||
applyAudioOutputRouting()
|
||||
UIDevice.current.isProximityMonitoringEnabled = true
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
callLogger.info("[Call] AudioSession configured: category=\(session.category.rawValue, privacy: .public) mode=\(session.mode.rawValue, privacy: .public)")
|
||||
}
|
||||
|
||||
func deactivateAudioSession() {
|
||||
UIDevice.current.isProximityMonitoringEnabled = false
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
// Only call setActive(false) if CallKit's didDeactivate did NOT fire.
|
||||
// CallKit's didDeactivate already calls audioSessionDidDeactivate() which
|
||||
// decrements WebRTC's internal activation count. Calling setActive(false)
|
||||
// again would double-decrement, breaking audio on the next call.
|
||||
if !didReceiveCallKitDeactivation {
|
||||
rtcSession.lockForConfiguration()
|
||||
try? rtcSession.setActive(false)
|
||||
rtcSession.unlockForConfiguration()
|
||||
}
|
||||
rtcSession.isAudioEnabled = false
|
||||
didReceiveCallKitDeactivation = false
|
||||
}
|
||||
|
||||
func applyAudioOutputRouting() {
|
||||
@@ -454,7 +455,11 @@ extension CallManager {
|
||||
guard let currentPeerConnection = self.peerConnection else { return }
|
||||
guard !bufferedRemoteCandidates.isEmpty else { return }
|
||||
for candidate in bufferedRemoteCandidates {
|
||||
try? await currentPeerConnection.add(candidate)
|
||||
do {
|
||||
try await currentPeerConnection.add(candidate)
|
||||
} catch {
|
||||
callLogger.warning("[Call] Failed to add buffered ICE candidate: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
bufferedRemoteCandidates.removeAll()
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ final class CallManager: NSObject, ObservableObject {
|
||||
/// WebRTC peer connection MUST NOT be created before this flag is true,
|
||||
/// otherwise AURemoteIO init fails with "Missing entitlement" (-12988).
|
||||
var audioSessionActivated = false
|
||||
/// True after CallKit fires didDeactivate. Prevents double-deactivation in
|
||||
/// deactivateAudioSession() — calling setActive(false) after CallKit already
|
||||
/// deactivated decrements WebRTC's internal counter below zero, breaking
|
||||
/// audio on the next call.
|
||||
var didReceiveCallKitDeactivation = false
|
||||
/// Buffered when .createRoom arrives before didActivate. Flushed in onAudioSessionActivated().
|
||||
var pendingWebRtcSetup = false
|
||||
/// Buffers WebRTC packets (OFFER/ANSWER/ICE) from SFU that arrive before
|
||||
@@ -96,7 +101,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
func setupIncomingCallFromPush(callerKey: String, callerName: String) {
|
||||
guard uiState.phase == .idle else { return }
|
||||
guard !callerKey.isEmpty else { return }
|
||||
callLogger.info("setupIncomingCallFromPush: callerKey=\(callerKey.prefix(12), privacy: .public) name=\(callerName, privacy: .public)")
|
||||
callLogger.notice("setupIncomingCallFromPush: callerKey=\(callerKey.prefix(12), privacy: .public) name=\(callerName, privacy: .public)")
|
||||
// Don't call beginCallSession() — it calls finishCall() which kills the
|
||||
// CallKit call that PushKit just reported. Set state directly instead.
|
||||
uiState = CallUiState(
|
||||
@@ -116,7 +121,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// not yet connected, so CXAnswerCallAction fired before phase was .incoming.
|
||||
if pendingCallKitAccept {
|
||||
pendingCallKitAccept = false
|
||||
callLogger.info("setupIncomingCallFromPush: auto-accepting (pendingCallKitAccept)")
|
||||
callLogger.notice("setupIncomingCallFromPush: auto-accepting (pendingCallKitAccept)")
|
||||
let result = acceptIncomingCall()
|
||||
callLogger.info("setupIncomingCallFromPush: auto-accept result=\(String(describing: result), privacy: .public)")
|
||||
}
|
||||
@@ -194,7 +199,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func endCall() {
|
||||
callLogger.info("[Call] endCall phase=\(self.uiState.phase.rawValue, privacy: .public)")
|
||||
callLogger.notice("[Call] endCall phase=\(self.uiState.phase.rawValue, privacy: .public)")
|
||||
finishCall(reason: nil, notifyPeer: true)
|
||||
}
|
||||
|
||||
@@ -238,25 +243,29 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// MARK: - Protocol handlers
|
||||
|
||||
private func wireProtocolHandlers() {
|
||||
// Priority .userInitiated — call signals MUST NOT wait behind sync batch.
|
||||
// During background wake-up, sync floods MainActor with hundreds of message
|
||||
// Tasks (default priority). Without elevated priority, call signal Tasks
|
||||
// queue behind sync → keyExchange delayed → Desktop gives up → call fails.
|
||||
signalToken = ProtocolManager.shared.addSignalPeerHandler { [weak self] packet in
|
||||
Task { @MainActor [weak self] in
|
||||
Task(priority: .userInitiated) { @MainActor [weak self] in
|
||||
self?.handleSignalPacket(packet)
|
||||
}
|
||||
}
|
||||
webRtcToken = ProtocolManager.shared.addWebRtcHandler { [weak self] packet in
|
||||
Task { @MainActor [weak self] in
|
||||
Task(priority: .userInitiated) { @MainActor [weak self] in
|
||||
await self?.handleWebRtcPacket(packet)
|
||||
}
|
||||
}
|
||||
iceToken = ProtocolManager.shared.addIceServersHandler { [weak self] packet in
|
||||
Task { @MainActor [weak self] in
|
||||
Task(priority: .userInitiated) { @MainActor [weak self] in
|
||||
self?.handleIceServersPacket(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSignalPacket(_ packet: PacketSignalPeer) {
|
||||
callLogger.info("[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)")
|
||||
callLogger.notice("[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)")
|
||||
switch packet.signalType {
|
||||
case .endCallBecauseBusy:
|
||||
finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true)
|
||||
@@ -334,17 +343,33 @@ final class CallManager: NSObject, ObservableObject {
|
||||
roomId = incomingRoomId
|
||||
uiState.phase = .webRtcExchange
|
||||
uiState.statusText = "Connecting..."
|
||||
// Defer WebRTC peer connection setup until CallKit grants audio entitlement.
|
||||
// Creating RTCPeerConnection + audio track BEFORE didActivate causes
|
||||
// AURemoteIO to fail with "Missing entitlement" (-12988), poisoning audio
|
||||
// for the entire call. If didActivate already fired, proceed immediately.
|
||||
if audioSessionActivated {
|
||||
Task { [weak self] in
|
||||
Task(priority: .userInitiated) { [weak self] in
|
||||
await self?.ensurePeerConnectionAndOffer()
|
||||
}
|
||||
} else {
|
||||
pendingWebRtcSetup = true
|
||||
callLogger.info("[Call] Deferring WebRTC setup — waiting for CallKit didActivate")
|
||||
callLogger.notice("[Call] Deferring WebRTC setup — waiting for CallKit didActivate")
|
||||
// Safety: if didActivate never fires (background VoIP push race),
|
||||
// force audio session activation after 3 seconds. Without this,
|
||||
// the call hangs in webRtcExchange forever → Desktop gives up →
|
||||
// SFU room stays allocated → next call gets "busy".
|
||||
Task(priority: .userInitiated) { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
guard let self else { return }
|
||||
guard self.pendingWebRtcSetup, !self.audioSessionActivated else { return }
|
||||
callLogger.notice("[Call] didActivate timeout (3s) — force-activating audio session")
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.audioSessionDidActivate(AVAudioSession.sharedInstance())
|
||||
rtcSession.lockForConfiguration()
|
||||
try? rtcSession.setCategory(
|
||||
.playAndRecord, mode: .voiceChat,
|
||||
options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker]
|
||||
)
|
||||
rtcSession.unlockForConfiguration()
|
||||
rtcSession.isAudioEnabled = true
|
||||
await self.onAudioSessionActivated()
|
||||
}
|
||||
}
|
||||
case .activeCall:
|
||||
break
|
||||
@@ -543,9 +568,17 @@ final class CallManager: NSObject, ObservableObject {
|
||||
func startE2EERebindLoop() {
|
||||
e2eeRebindTask?.cancel()
|
||||
e2eeRebindTask = Task { @MainActor [weak self] in
|
||||
// First iteration runs immediately (no sleep) — attaches cryptors
|
||||
// to any senders/receivers created during peer connection setup.
|
||||
// Subsequent iterations run every 1.5s (Android parity).
|
||||
var isFirstIteration = true
|
||||
while !Task.isCancelled {
|
||||
if isFirstIteration {
|
||||
isFirstIteration = false
|
||||
} else {
|
||||
try? await Task.sleep(for: .milliseconds(1500))
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
guard let self else { return }
|
||||
guard self.uiState.phase == .webRtcExchange || self.uiState.phase == .active else { continue }
|
||||
guard self.sharedKey != nil, let pc = self.peerConnection else { continue }
|
||||
@@ -582,8 +615,10 @@ final class CallManager: NSObject, ObservableObject {
|
||||
// Android waits 15s. Previous iOS code killed instantly → unstable calls.
|
||||
startDisconnectRecoveryTimer(timeout: 15)
|
||||
case .failed:
|
||||
// More serious — unlikely to recover. Shorter timeout than .disconnected.
|
||||
startDisconnectRecoveryTimer(timeout: 5)
|
||||
// More serious — unlikely to recover. Android uses 12s for PeerConnectionState.FAILED
|
||||
// (CallManager.kt:820). Previous iOS value was 5s — too aggressive, dropped calls
|
||||
// that Android would recover from on poor networks.
|
||||
startDisconnectRecoveryTimer(timeout: 12)
|
||||
case .closed:
|
||||
finishCall(reason: "Connection closed", notifyPeer: false)
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AudioToolbox
|
||||
import AVFAudio
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@MainActor
|
||||
final class CallSoundManager {
|
||||
@@ -67,7 +68,7 @@ final class CallSoundManager {
|
||||
|
||||
private func playLoop(_ name: String) {
|
||||
guard let url = Bundle.main.url(forResource: name, withExtension: "mp3") else {
|
||||
print("[CallSound] Sound file not found: \(name).mp3")
|
||||
callLogger.warning("[CallSound] Sound file not found: \(name, privacy: .public).mp3")
|
||||
return
|
||||
}
|
||||
do {
|
||||
@@ -78,13 +79,13 @@ final class CallSoundManager {
|
||||
player.play()
|
||||
loopingPlayer = player
|
||||
} catch {
|
||||
print("[CallSound] Failed to play \(name): \(error)")
|
||||
callLogger.error("[CallSound] Failed to play \(name, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func playOneShot(_ name: String) {
|
||||
guard let url = Bundle.main.url(forResource: name, withExtension: "mp3") else {
|
||||
print("[CallSound] Sound file not found: \(name).mp3")
|
||||
callLogger.warning("[CallSound] Sound file not found: \(name, privacy: .public).mp3")
|
||||
return
|
||||
}
|
||||
do {
|
||||
@@ -95,7 +96,7 @@ final class CallSoundManager {
|
||||
player.play()
|
||||
oneShotPlayer = player
|
||||
} catch {
|
||||
print("[CallSound] Failed to play \(name): \(error)")
|
||||
callLogger.error("[CallSound] Failed to play \(name, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
299
Rosetta/Core/Services/GroupService.swift
Normal file
299
Rosetta/Core/Services/GroupService.swift
Normal file
@@ -0,0 +1,299 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - GroupService
|
||||
|
||||
/// Orchestrates multi-step group operations (create, join, leave, kick, members).
|
||||
/// Uses PacketAwaiter for async request-response exchanges with the server.
|
||||
@MainActor
|
||||
final class GroupService {
|
||||
static let shared = GroupService()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "GroupService")
|
||||
|
||||
private let groupRepo = GroupRepository.shared
|
||||
private let dialogRepo = DialogRepository.shared
|
||||
private let proto = ProtocolManager.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum GroupError: Error, LocalizedError {
|
||||
case notAuthenticated
|
||||
case serverReturnedEmptyId
|
||||
case failedToConstructInvite
|
||||
case failedToEncryptInvite
|
||||
case joinRejected(GroupStatus)
|
||||
case invalidInviteString
|
||||
case timeout
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthenticated: return "Not connected to server"
|
||||
case .serverReturnedEmptyId: return "Server returned empty group ID"
|
||||
case .failedToConstructInvite: return "Failed to construct invite string"
|
||||
case .failedToEncryptInvite: return "Failed to encrypt invite string"
|
||||
case .joinRejected(let status): return "Join rejected: \(status)"
|
||||
case .invalidInviteString: return "Invalid invite string"
|
||||
case .timeout: return "Server did not respond in time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create Group
|
||||
|
||||
/// Creates a new group: requests ID from server, generates key, joins, persists locally.
|
||||
/// Returns a ChatRoute for navigating to the new group chat.
|
||||
func createGroup(title: String, description: String = "") async throws -> ChatRoute {
|
||||
let session = SessionManager.shared
|
||||
let account = session.currentPublicKey
|
||||
guard let privateKeyHex = session.privateKeyHex, !account.isEmpty else {
|
||||
throw GroupError.notAuthenticated
|
||||
}
|
||||
|
||||
Self.logger.info("Creating group: \(title)")
|
||||
|
||||
// Step 1: Request group ID from server.
|
||||
let createResponse: PacketCreateGroup = try await PacketAwaiter.send(
|
||||
PacketCreateGroup(),
|
||||
awaitResponse: 0x11,
|
||||
where: { $0.groupId.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 }
|
||||
)
|
||||
let groupId = createResponse.groupId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !groupId.isEmpty else { throw GroupError.serverReturnedEmptyId }
|
||||
Self.logger.info("Server assigned groupId: \(groupId)")
|
||||
|
||||
// Step 2: Generate group encryption key (Android parity: 32 random bytes → hex).
|
||||
let groupKey = groupRepo.generateGroupKey()
|
||||
|
||||
// Step 3: Construct invite string.
|
||||
guard let inviteString = groupRepo.constructInviteString(
|
||||
groupId: groupId,
|
||||
title: title,
|
||||
encryptKey: groupKey,
|
||||
description: description
|
||||
) else {
|
||||
throw GroupError.failedToConstructInvite
|
||||
}
|
||||
|
||||
// Step 4: Encrypt invite with private key for server storage (sync to other devices).
|
||||
guard let encryptedGroupString = try? CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
Data(inviteString.utf8),
|
||||
password: privateKeyHex
|
||||
) else {
|
||||
throw GroupError.failedToEncryptInvite
|
||||
}
|
||||
|
||||
// Step 5: Join the group we just created.
|
||||
var joinPacket = PacketGroupJoin()
|
||||
joinPacket.groupId = groupId
|
||||
joinPacket.status = .notJoined
|
||||
joinPacket.groupString = encryptedGroupString
|
||||
|
||||
let joinResponse: PacketGroupJoin = try await PacketAwaiter.send(
|
||||
joinPacket,
|
||||
awaitResponse: 0x14,
|
||||
where: { $0.groupId == groupId }
|
||||
)
|
||||
|
||||
guard joinResponse.status == .joined else {
|
||||
throw GroupError.joinRejected(joinResponse.status)
|
||||
}
|
||||
|
||||
// Step 6: Persist group locally.
|
||||
let dialogKey = groupRepo.persistGroup(
|
||||
account: account,
|
||||
privateKeyHex: privateKeyHex,
|
||||
groupId: groupId,
|
||||
title: title,
|
||||
description: description,
|
||||
encryptKey: groupKey
|
||||
) ?? groupRepo.toGroupDialogKey(groupId)
|
||||
|
||||
// Step 7: Ensure dialog exists in chat list.
|
||||
dialogRepo.ensureDialog(
|
||||
opponentKey: dialogKey,
|
||||
title: title,
|
||||
username: description,
|
||||
myPublicKey: account
|
||||
)
|
||||
|
||||
Self.logger.info("Group created successfully: \(dialogKey)")
|
||||
return ChatRoute(groupDialogKey: dialogKey, title: title, description: description)
|
||||
}
|
||||
|
||||
// MARK: - Join Group
|
||||
|
||||
/// Joins an existing group from an invite string.
|
||||
func joinGroup(inviteString: String) async throws -> ChatRoute {
|
||||
let session = SessionManager.shared
|
||||
let account = session.currentPublicKey
|
||||
guard let privateKeyHex = session.privateKeyHex, !account.isEmpty else {
|
||||
throw GroupError.notAuthenticated
|
||||
}
|
||||
|
||||
guard let parsed = groupRepo.parseInviteString(inviteString) else {
|
||||
throw GroupError.invalidInviteString
|
||||
}
|
||||
|
||||
Self.logger.info("Joining group: \(parsed.groupId)")
|
||||
|
||||
// Encrypt invite with private key for server sync.
|
||||
guard let encryptedGroupString = try? CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
Data(inviteString.utf8),
|
||||
password: privateKeyHex
|
||||
) else {
|
||||
throw GroupError.failedToEncryptInvite
|
||||
}
|
||||
|
||||
var joinPacket = PacketGroupJoin()
|
||||
joinPacket.groupId = parsed.groupId
|
||||
joinPacket.status = .notJoined
|
||||
joinPacket.groupString = encryptedGroupString
|
||||
|
||||
let response: PacketGroupJoin = try await PacketAwaiter.send(
|
||||
joinPacket,
|
||||
awaitResponse: 0x14,
|
||||
where: { $0.groupId == parsed.groupId }
|
||||
)
|
||||
|
||||
guard response.status == .joined else {
|
||||
throw GroupError.joinRejected(response.status)
|
||||
}
|
||||
|
||||
let dialogKey = groupRepo.persistGroup(
|
||||
account: account,
|
||||
privateKeyHex: privateKeyHex,
|
||||
groupId: parsed.groupId,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
encryptKey: parsed.encryptKey
|
||||
) ?? groupRepo.toGroupDialogKey(parsed.groupId)
|
||||
|
||||
dialogRepo.ensureDialog(
|
||||
opponentKey: dialogKey,
|
||||
title: parsed.title,
|
||||
username: parsed.description,
|
||||
myPublicKey: account
|
||||
)
|
||||
|
||||
Self.logger.info("Joined group: \(dialogKey)")
|
||||
return ChatRoute(groupDialogKey: dialogKey, title: parsed.title, description: parsed.description)
|
||||
}
|
||||
|
||||
// MARK: - Leave Group
|
||||
|
||||
/// Leaves a group and deletes all local data.
|
||||
func leaveGroup(groupDialogKey: String) async throws {
|
||||
let session = SessionManager.shared
|
||||
let account = session.currentPublicKey
|
||||
guard !account.isEmpty else { throw GroupError.notAuthenticated }
|
||||
|
||||
let groupId = groupRepo.normalizeGroupId(groupDialogKey)
|
||||
guard !groupId.isEmpty else { return }
|
||||
|
||||
Self.logger.info("Leaving group: \(groupId)")
|
||||
|
||||
var leavePacket = PacketGroupLeave()
|
||||
leavePacket.groupId = groupId
|
||||
|
||||
let _: PacketGroupLeave = try await PacketAwaiter.send(
|
||||
leavePacket,
|
||||
awaitResponse: 0x15,
|
||||
where: { $0.groupId == groupId }
|
||||
)
|
||||
|
||||
// Delete local data.
|
||||
groupRepo.deleteGroup(account: account, groupDialogKey: groupDialogKey)
|
||||
dialogRepo.deleteDialog(opponentKey: groupDialogKey)
|
||||
|
||||
Self.logger.info("Left group: \(groupId)")
|
||||
}
|
||||
|
||||
// MARK: - Kick Member (Admin)
|
||||
|
||||
/// Bans a member from the group (admin only — server validates).
|
||||
/// Server responds with updated PacketGroupInfo (0x12) after successful ban.
|
||||
func kickMember(groupDialogKey: String, memberPublicKey: String) async throws {
|
||||
let groupId = groupRepo.normalizeGroupId(groupDialogKey)
|
||||
guard !groupId.isEmpty else { return }
|
||||
|
||||
Self.logger.info("Kicking \(memberPublicKey.prefix(12)) from group \(groupId)")
|
||||
|
||||
var banPacket = PacketGroupBan()
|
||||
banPacket.groupId = groupId
|
||||
banPacket.publicKey = memberPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let _: PacketGroupInfo = try await PacketAwaiter.send(
|
||||
banPacket,
|
||||
awaitResponse: 0x12,
|
||||
where: { $0.groupId == groupId }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Request Members
|
||||
|
||||
/// Fetches the member list for a group from the server.
|
||||
func requestMembers(groupDialogKey: String) async throws -> [String] {
|
||||
let groupId = groupRepo.normalizeGroupId(groupDialogKey)
|
||||
guard !groupId.isEmpty else { return [] }
|
||||
|
||||
var infoPacket = PacketGroupInfo()
|
||||
infoPacket.groupId = groupId
|
||||
|
||||
let response: PacketGroupInfo = try await PacketAwaiter.send(
|
||||
infoPacket,
|
||||
awaitResponse: 0x12,
|
||||
where: { $0.groupId == groupId }
|
||||
)
|
||||
|
||||
return response.members
|
||||
}
|
||||
|
||||
// MARK: - Check Invite Status
|
||||
|
||||
/// Checks if the current user can join a group (pre-join status check).
|
||||
func checkInviteStatus(groupId: String) async throws -> (memberCount: Int, status: GroupStatus) {
|
||||
let normalized = groupRepo.normalizeGroupId(groupId)
|
||||
guard !normalized.isEmpty else { return (0, .invalid) }
|
||||
|
||||
var packet = PacketGroupInviteInfo()
|
||||
packet.groupId = normalized
|
||||
|
||||
let response: PacketGroupInviteInfo = try await PacketAwaiter.send(
|
||||
packet,
|
||||
awaitResponse: 0x13,
|
||||
where: { $0.groupId == normalized }
|
||||
)
|
||||
|
||||
return (response.membersCount, response.status)
|
||||
}
|
||||
|
||||
// MARK: - Generate Invite String
|
||||
|
||||
/// Generates a shareable invite string for a group the user is admin of.
|
||||
func generateInviteString(groupDialogKey: String) -> String? {
|
||||
let session = SessionManager.shared
|
||||
let account = session.currentPublicKey
|
||||
guard let privateKeyHex = session.privateKeyHex, !account.isEmpty else { return nil }
|
||||
|
||||
let groupId = groupRepo.normalizeGroupId(groupDialogKey)
|
||||
guard !groupId.isEmpty else { return nil }
|
||||
|
||||
guard let groupKey = groupRepo.groupKey(
|
||||
account: account,
|
||||
privateKeyHex: privateKeyHex,
|
||||
groupDialogKey: groupDialogKey
|
||||
) else { return nil }
|
||||
|
||||
let metadata = groupRepo.groupMetadata(account: account, groupDialogKey: groupDialogKey)
|
||||
|
||||
return groupRepo.constructInviteString(
|
||||
groupId: groupId,
|
||||
title: metadata?.title ?? "",
|
||||
encryptKey: groupKey,
|
||||
description: metadata?.description ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ final class SessionManager {
|
||||
/// Desktop parity: exposed so chat list can suppress unread badges during sync.
|
||||
private(set) var syncBatchInProgress = false
|
||||
private var syncRequestInFlight = false
|
||||
/// One-time flag: prevents infinite recovery sync loop.
|
||||
private var hasTriggeredGroupRecoverySync = false
|
||||
private var pendingIncomingMessages: [PacketMessage] = []
|
||||
private var isProcessingIncomingMessages = false
|
||||
/// Android parity: tracks the latest incoming message timestamp per dialog
|
||||
@@ -373,8 +375,19 @@ final class SessionManager {
|
||||
|
||||
// Send via WebSocket (queued if offline, sent directly if online)
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
|
||||
// Server doesn't send delivery ACK (0x08) for group messages —
|
||||
// MessageDispatcher.sendGroup() only distributes to members, no ACK back.
|
||||
// Mark as delivered immediately (same pattern as Saved Messages).
|
||||
if DatabaseManager.isGroupDialogKey(targetDialogKey) {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||
DialogRepository.shared.updateDeliveryStatus(
|
||||
messageId: messageId, opponentKey: targetDialogKey, status: .delivered
|
||||
)
|
||||
} else {
|
||||
registerOutgoingRetry(for: packet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends current user's avatar to a chat as a message attachment.
|
||||
/// Desktop parity: `onClickCamera()` in `DialogInput.tsx` → loads avatar → attaches as AVATAR type
|
||||
@@ -556,15 +569,52 @@ final class SessionManager {
|
||||
// Android parity: no caption → encrypt empty string "".
|
||||
// Receivers decrypt "" → show "Photo"/"File" in chat list.
|
||||
let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)
|
||||
|
||||
// Group vs direct: different encryption paths.
|
||||
let attachmentPassword: String
|
||||
let encryptedContent: String
|
||||
let outChachaKey: String
|
||||
let outAesChachaKey: String
|
||||
|
||||
if isGroup {
|
||||
let normalizedGroup = Self.normalizedGroupDialogIdentity(toPublicKey)
|
||||
guard let groupKey = GroupRepository.shared.groupKey(
|
||||
account: currentPublicKey,
|
||||
privateKeyHex: privKey,
|
||||
groupDialogKey: normalizedGroup
|
||||
) else {
|
||||
throw CryptoError.invalidData("Missing group key for \(normalizedGroup)")
|
||||
}
|
||||
// Group: encrypt text and attachments with shared group key.
|
||||
encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
Data((messageText.isEmpty ? "" : messageText).utf8),
|
||||
password: groupKey
|
||||
)
|
||||
// Desktop parity: Buffer.from(groupKey).toString('hex') — hex of UTF-8 bytes
|
||||
attachmentPassword = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined()
|
||||
outChachaKey = ""
|
||||
outAesChachaKey = ""
|
||||
} else {
|
||||
let encrypted = try MessageCrypto.encryptOutgoing(
|
||||
plaintext: messageText.isEmpty ? "" : messageText,
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
|
||||
// Attachment password: HEX encoding of raw 56-byte key+nonce.
|
||||
// Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex').
|
||||
// HEX is lossless for all byte values (no U+FFFD data loss).
|
||||
let attachmentPassword = encrypted.plainKeyAndNonce.hexString
|
||||
attachmentPassword = encrypted.plainKeyAndNonce.hexString
|
||||
encryptedContent = encrypted.content
|
||||
outChachaKey = encrypted.chachaKey
|
||||
|
||||
guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
||||
throw CryptoError.encryptionFailed
|
||||
}
|
||||
let aesChachaPayload = Data(latin1String.utf8)
|
||||
outAesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
aesChachaPayload,
|
||||
password: privKey
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let pwdUtf8Bytes = Array(attachmentPassword.utf8)
|
||||
@@ -650,52 +700,17 @@ final class SessionManager {
|
||||
)
|
||||
}
|
||||
|
||||
// Build aesChachaKey (for sync/backup — same encoding as makeOutgoingPacket).
|
||||
// MUST use Latin-1 (not WHATWG UTF-8) so desktop can recover original raw bytes
|
||||
// via Buffer.from(decryptedString, 'binary') which takes the low byte of each char.
|
||||
// Latin-1 maps every byte 0x00-0xFF to its codepoint losslessly.
|
||||
// WHATWG UTF-8 replaces invalid sequences with U+FFFD (codepoint 0xFFFD) —
|
||||
// Buffer.from('\uFFFD', 'binary') recovers 0xFD, not the original byte.
|
||||
guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
||||
throw CryptoError.encryptionFailed
|
||||
}
|
||||
let aesChachaPayload = Data(latin1ForSync.utf8)
|
||||
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
aesChachaPayload,
|
||||
password: privKey
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
// aesChachaKey round-trip self-test: simulates EXACT desktop sync chain.
|
||||
do {
|
||||
let rtDecrypted = try CryptoManager.shared.decryptWithPassword(
|
||||
aesChachaKey, password: privKey, requireCompression: true
|
||||
)
|
||||
guard let rtString = String(data: rtDecrypted, encoding: .utf8) else {
|
||||
Self.logger.error("📎 aesChachaKey FAILED — not valid UTF-8")
|
||||
throw CryptoError.decryptionFailed
|
||||
}
|
||||
// Simulate Buffer.from(string, 'binary').toString('hex')
|
||||
let rtRawBytes = Data(rtString.unicodeScalars.map { UInt8($0.value & 0xFF) })
|
||||
let rtHex = rtRawBytes.hexString
|
||||
let match = rtHex == attachmentPassword
|
||||
Self.logger.debug("📎 aesChachaKey roundtrip: \(match ? "PASS" : "FAIL") (\(rtRawBytes.count) bytes recovered)")
|
||||
} catch {
|
||||
Self.logger.error("📎 aesChachaKey roundtrip FAILED: \(error)")
|
||||
}
|
||||
#endif
|
||||
|
||||
// ── Optimistic UI: show message INSTANTLY with placeholder attachments ──
|
||||
// Android parity: addMessageSafely(optimisticMessage) before upload.
|
||||
var optimisticPacket = PacketMessage()
|
||||
optimisticPacket.fromPublicKey = currentPublicKey
|
||||
optimisticPacket.toPublicKey = toPublicKey
|
||||
optimisticPacket.content = encrypted.content
|
||||
optimisticPacket.chachaKey = encrypted.chachaKey
|
||||
optimisticPacket.toPublicKey = isGroup ? Self.normalizedGroupDialogIdentity(toPublicKey) : toPublicKey
|
||||
optimisticPacket.content = encryptedContent
|
||||
optimisticPacket.chachaKey = outChachaKey
|
||||
optimisticPacket.timestamp = timestamp
|
||||
optimisticPacket.privateKey = hash
|
||||
optimisticPacket.messageId = messageId
|
||||
optimisticPacket.aesChachaKey = aesChachaKey
|
||||
optimisticPacket.aesChachaKey = outAesChachaKey
|
||||
optimisticPacket.attachments = placeholderAttachments
|
||||
|
||||
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
||||
@@ -709,11 +724,13 @@ final class SessionManager {
|
||||
|
||||
// Android parity: always insert as WAITING (fromSync: false).
|
||||
// Retry mechanism handles timeout (80s → ERROR).
|
||||
let optimisticDialogKey = isGroup ? Self.normalizedGroupDialogIdentity(toPublicKey) : toPublicKey
|
||||
let optimisticAttachmentPwd = isGroup ? attachmentPassword : ("rawkey:" + attachmentPassword)
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText,
|
||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: false
|
||||
attachmentPassword: optimisticAttachmentPwd, fromSync: false
|
||||
)
|
||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: toPublicKey)
|
||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: optimisticDialogKey)
|
||||
MessageRepository.shared.persistNow()
|
||||
|
||||
if toPublicKey == currentPublicKey {
|
||||
@@ -774,7 +791,14 @@ final class SessionManager {
|
||||
packet.attachments = messageAttachments
|
||||
|
||||
packetFlowSender.sendPacket(packet)
|
||||
if isGroup {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||
DialogRepository.shared.updateDeliveryStatus(
|
||||
messageId: messageId, opponentKey: optimisticDialogKey, status: .delivered
|
||||
)
|
||||
} else {
|
||||
registerOutgoingRetry(for: packet)
|
||||
}
|
||||
MessageRepository.shared.persistNow()
|
||||
Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…")
|
||||
}
|
||||
@@ -951,7 +975,14 @@ final class SessionManager {
|
||||
}
|
||||
|
||||
packetFlowSender.sendPacket(packet)
|
||||
if DatabaseManager.isGroupDialogKey(toPublicKey) {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||
DialogRepository.shared.updateDeliveryStatus(
|
||||
messageId: messageId, opponentKey: toPublicKey, status: .delivered
|
||||
)
|
||||
} else {
|
||||
registerOutgoingRetry(for: packet)
|
||||
}
|
||||
MessageRepository.shared.persistNow()
|
||||
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)")
|
||||
}
|
||||
@@ -1144,6 +1175,7 @@ final class SessionManager {
|
||||
MessageRepository.shared.reset()
|
||||
DatabaseManager.shared.close()
|
||||
CryptoManager.shared.clearCaches()
|
||||
GroupRepository.shared.clearCaches()
|
||||
AttachmentCache.shared.privateKey = nil
|
||||
RecentSearchesRepository.shared.clearSession()
|
||||
DraftManager.shared.reset()
|
||||
@@ -1243,7 +1275,7 @@ final class SessionManager {
|
||||
return
|
||||
}
|
||||
if context.fromMe { return }
|
||||
MessageRepository.shared.markTyping(from: context.dialogKey)
|
||||
MessageRepository.shared.markTyping(from: context.dialogKey, senderKey: context.fromKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1397,6 +1429,8 @@ final class SessionManager {
|
||||
DialogRepository.shared.reconcileUnreadCounts()
|
||||
self.retryWaitingOutgoingMessagesAfterReconnect()
|
||||
Self.logger.debug("SYNC NOT_NEEDED")
|
||||
// One-time recovery: re-sync from 0 if group keys are missing.
|
||||
self.recoverOrphanedGroupsIfNeeded()
|
||||
// Refresh user info now that sync is done.
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
@@ -1450,12 +1484,20 @@ final class SessionManager {
|
||||
myPublicKey: self.currentPublicKey
|
||||
)
|
||||
|
||||
if MessageRepository.shared.lastDecryptedMessage(
|
||||
// Re-decrypt messages that arrived before the group key was available.
|
||||
if let gk = GroupRepository.shared.groupKey(
|
||||
account: self.currentPublicKey,
|
||||
opponentKey: dialogKey
|
||||
) != nil {
|
||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey)
|
||||
privateKeyHex: privateKeyHex,
|
||||
groupDialogKey: dialogKey
|
||||
) {
|
||||
MessageRepository.shared.reDecryptGroupMessages(
|
||||
account: self.currentPublicKey,
|
||||
groupDialogKey: dialogKey,
|
||||
groupKey: gk,
|
||||
privateKeyHex: privateKeyHex
|
||||
)
|
||||
}
|
||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1825,6 +1867,42 @@ final class SessionManager {
|
||||
DatabaseManager.shared.saveSyncCursor(account: currentPublicKey, timestamp: raw)
|
||||
}
|
||||
|
||||
// MARK: - Orphaned Group Recovery
|
||||
|
||||
/// Detects group dialogs without a key in the `groups` table and triggers a full
|
||||
/// re-sync (cursor=0) to recover the missing `PacketGroupJoin` from the server buffer.
|
||||
/// Runs once per session to avoid infinite sync loops.
|
||||
private func recoverOrphanedGroupsIfNeeded() {
|
||||
guard !hasTriggeredGroupRecoverySync else { return }
|
||||
guard let privKey = privateKeyHex, !currentPublicKey.isEmpty else { return }
|
||||
|
||||
let groupDialogKeys = DialogRepository.shared.dialogs.keys.filter {
|
||||
DatabaseManager.isGroupDialogKey($0)
|
||||
}
|
||||
guard !groupDialogKeys.isEmpty else { return }
|
||||
|
||||
var orphaned = false
|
||||
for key in groupDialogKeys {
|
||||
if GroupRepository.shared.groupKey(
|
||||
account: currentPublicKey,
|
||||
privateKeyHex: privKey,
|
||||
groupDialogKey: key
|
||||
) == nil {
|
||||
Self.logger.warning("Orphaned group detected (no key): \(key)")
|
||||
orphaned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard orphaned else { return }
|
||||
|
||||
hasTriggeredGroupRecoverySync = true
|
||||
Self.logger.info("Resetting sync cursor to 0 for orphaned group key recovery")
|
||||
DatabaseManager.shared.resetSyncCursor(account: currentPublicKey)
|
||||
syncRequestInFlight = false
|
||||
requestSynchronize()
|
||||
}
|
||||
|
||||
// MARK: - Background Crypto (off MainActor)
|
||||
|
||||
/// Result of background crypto operations for an incoming message.
|
||||
@@ -1885,6 +1963,8 @@ final class SessionManager {
|
||||
}
|
||||
}
|
||||
} else if let groupKey {
|
||||
// Desktop parity: Buffer.from(groupKey).toString('hex')
|
||||
resolvedAttachmentPassword = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined()
|
||||
for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages {
|
||||
let blob = processedPacket.attachments[i].blob
|
||||
guard !blob.isEmpty else { continue }
|
||||
@@ -2346,6 +2426,14 @@ final class SessionManager {
|
||||
if message.toPublicKey == currentPublicKey {
|
||||
continue
|
||||
}
|
||||
// Group messages don't get server ACK — skip retry, mark delivered.
|
||||
if DatabaseManager.isGroupDialogKey(message.toPublicKey) {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .delivered)
|
||||
DialogRepository.shared.updateDeliveryStatus(
|
||||
messageId: message.id, opponentKey: message.toPublicKey, status: .delivered
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { continue }
|
||||
|
||||
@@ -157,6 +157,39 @@ enum RosettaColors {
|
||||
return UIColor(avatarColors[safeIndex].tint)
|
||||
}
|
||||
|
||||
/// Returns UIColor text variant for sender name labels in group chats (UIKit).
|
||||
static func avatarTextColor(for index: Int) -> UIColor {
|
||||
let safeIndex = index % avatarColors.count
|
||||
return UIColor(avatarColors[safeIndex].text)
|
||||
}
|
||||
|
||||
// MARK: - Telegram PeerNameColors (group sender names)
|
||||
|
||||
/// 7 Telegram-parity sender name colors for group chats.
|
||||
/// Dark theme: saturation ×0.7, brightness ×1.2 (from Telegram iOS source).
|
||||
static let peerNameColors: [UIColor] = [
|
||||
UIColor(red: 0xcc/255, green: 0x50/255, blue: 0x49/255, alpha: 1), // red
|
||||
UIColor(red: 0xd6/255, green: 0x77/255, blue: 0x22/255, alpha: 1), // orange
|
||||
UIColor(red: 0x95/255, green: 0x5c/255, blue: 0xdb/255, alpha: 1), // purple
|
||||
UIColor(red: 0x40/255, green: 0xa9/255, blue: 0x20/255, alpha: 1), // green
|
||||
UIColor(red: 0x30/255, green: 0x9e/255, blue: 0xba/255, alpha: 1), // teal
|
||||
UIColor(red: 0x36/255, green: 0x8a/255, blue: 0xd1/255, alpha: 1), // blue
|
||||
UIColor(red: 0xc7/255, green: 0x50/255, blue: 0x8b/255, alpha: 1), // pink
|
||||
].map { adjustForDarkTheme($0) }
|
||||
|
||||
/// Telegram dark theme brightness adjustment.
|
||||
private static func adjustForDarkTheme(_ color: UIColor) -> UIColor {
|
||||
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
color.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
||||
return UIColor(hue: h, saturation: s * 0.7, brightness: min(1.0, b * 1.2), alpha: a)
|
||||
}
|
||||
|
||||
/// Returns a Telegram PeerNameColor for a sender based on their public key.
|
||||
static func peerNameColor(forKey publicKey: String) -> UIColor {
|
||||
let index = avatarColorIndex(for: publicKey, publicKey: publicKey)
|
||||
return peerNameColors[index % peerNameColors.count]
|
||||
}
|
||||
|
||||
static func avatarText(publicKey: String) -> String {
|
||||
String(publicKey.prefix(2)).uppercased()
|
||||
}
|
||||
|
||||
@@ -40,10 +40,13 @@ struct ChatDetailView: View {
|
||||
@State private var showNoAvatarAlert = false
|
||||
@State private var pendingAttachments: [PendingAttachment] = []
|
||||
@State private var showOpponentProfile = false
|
||||
@State private var showGroupInfo = false
|
||||
@State private var callErrorMessage: String?
|
||||
@State private var replyingToMessage: ChatMessage?
|
||||
@State private var showForwardPicker = false
|
||||
@State private var forwardingMessage: ChatMessage?
|
||||
@State private var pendingGroupInvite: String?
|
||||
@State private var pendingGroupInviteTitle: String?
|
||||
@State private var messageToDelete: ChatMessage?
|
||||
// Image viewer is presented via ImageViewerPresenter (UIKit overFullScreen),
|
||||
// not via SwiftUI fullScreenCover, to avoid bottom-sheet slide-up animation.
|
||||
@@ -76,6 +79,14 @@ struct ChatDetailView: View {
|
||||
|
||||
private var titleText: String {
|
||||
if route.isSavedMessages { return "Saved Messages" }
|
||||
if route.isGroup {
|
||||
if let meta = GroupRepository.shared.groupMetadata(
|
||||
account: SessionManager.shared.currentPublicKey,
|
||||
groupDialogKey: route.publicKey
|
||||
), !meta.title.isEmpty {
|
||||
return meta.title
|
||||
}
|
||||
}
|
||||
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
|
||||
if !route.title.isEmpty { return route.title }
|
||||
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
|
||||
@@ -91,8 +102,18 @@ struct ChatDetailView: View {
|
||||
|
||||
private var subtitleText: String {
|
||||
if route.isSavedMessages { return "" }
|
||||
// Desktop parity: system accounts show "official account" instead of online/offline
|
||||
if route.isSystemAccount { return "official account" }
|
||||
if route.isGroup {
|
||||
let names = viewModel.typingSenderNames
|
||||
if !names.isEmpty {
|
||||
if names.count == 1 {
|
||||
return "\(names[0]) typing..."
|
||||
} else {
|
||||
return "\(names[0]) and \(names.count - 1) typing..."
|
||||
}
|
||||
}
|
||||
return "group"
|
||||
}
|
||||
if isTyping { return "typing..." }
|
||||
if let dialog, dialog.isOnline { return "online" }
|
||||
return "offline"
|
||||
@@ -227,6 +248,12 @@ struct ChatDetailView: View {
|
||||
)
|
||||
if case .alreadyInCall = result { callErrorMessage = "You are already in another call." }
|
||||
}
|
||||
cellActions.onGroupInviteTap = { [self] invite in
|
||||
if let parsed = GroupRepository.shared.parseInviteString(invite) {
|
||||
pendingGroupInvite = invite
|
||||
pendingGroupInviteTitle = parsed.title
|
||||
}
|
||||
}
|
||||
// Capture first unread incoming message BEFORE marking as read.
|
||||
if firstUnreadMessageId == nil {
|
||||
firstUnreadMessageId = messages.first(where: {
|
||||
@@ -302,6 +329,9 @@ struct ChatDetailView: View {
|
||||
.navigationDestination(isPresented: $showOpponentProfile) {
|
||||
OpponentProfileView(route: route)
|
||||
}
|
||||
.navigationDestination(isPresented: $showGroupInfo) {
|
||||
GroupInfoView(groupDialogKey: route.publicKey)
|
||||
}
|
||||
.sheet(isPresented: $showForwardPicker) {
|
||||
ForwardChatPickerView { targetRoutes in
|
||||
showForwardPicker = false
|
||||
@@ -349,6 +379,34 @@ struct ChatDetailView: View {
|
||||
} message: {
|
||||
Text(callErrorMessage ?? "Failed to start call.")
|
||||
}
|
||||
.alert("Join Group", isPresented: Binding(
|
||||
get: { pendingGroupInvite != nil },
|
||||
set: { if !$0 { pendingGroupInvite = nil; pendingGroupInviteTitle = nil } }
|
||||
)) {
|
||||
Button("Join") {
|
||||
guard let invite = pendingGroupInvite else { return }
|
||||
pendingGroupInvite = nil
|
||||
Task {
|
||||
do {
|
||||
let route = try await GroupService.shared.joinGroup(inviteString: invite)
|
||||
pendingGroupInviteTitle = nil
|
||||
// Navigate to the new group
|
||||
NotificationCenter.default.post(
|
||||
name: .openChatFromNotification,
|
||||
object: ChatRoute(groupDialogKey: route.publicKey, title: route.title)
|
||||
)
|
||||
} catch {
|
||||
callErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
pendingGroupInvite = nil
|
||||
pendingGroupInviteTitle = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Join \"\(pendingGroupInviteTitle ?? "group")\"?")
|
||||
}
|
||||
.sheet(isPresented: $showAttachmentPanel) {
|
||||
AttachmentPanelView(
|
||||
onSend: { attachments, caption in
|
||||
@@ -394,6 +452,14 @@ private struct ChatDetailPrincipal: View {
|
||||
|
||||
private var titleText: String {
|
||||
if route.isSavedMessages { return "Saved Messages" }
|
||||
if route.isGroup {
|
||||
if let meta = GroupRepository.shared.groupMetadata(
|
||||
account: SessionManager.shared.currentPublicKey,
|
||||
groupDialogKey: route.publicKey
|
||||
), !meta.title.isEmpty {
|
||||
return meta.title
|
||||
}
|
||||
}
|
||||
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
|
||||
if !route.title.isEmpty { return route.title }
|
||||
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
|
||||
@@ -410,6 +476,17 @@ private struct ChatDetailPrincipal: View {
|
||||
private var subtitleText: String {
|
||||
if route.isSavedMessages { return "" }
|
||||
if route.isSystemAccount { return "official account" }
|
||||
if route.isGroup {
|
||||
let names = viewModel.typingSenderNames
|
||||
if !names.isEmpty {
|
||||
if names.count == 1 {
|
||||
return "\(names[0]) typing..."
|
||||
} else {
|
||||
return "\(names[0]) and \(names.count - 1) typing..."
|
||||
}
|
||||
}
|
||||
return "group"
|
||||
}
|
||||
if viewModel.isTyping { return "typing..." }
|
||||
if let dialog, dialog.isOnline { return "online" }
|
||||
return "offline"
|
||||
@@ -456,6 +533,14 @@ private struct ChatDetailToolbarAvatar: View {
|
||||
|
||||
private var titleText: String {
|
||||
if route.isSavedMessages { return "Saved Messages" }
|
||||
if route.isGroup {
|
||||
if let meta = GroupRepository.shared.groupMetadata(
|
||||
account: SessionManager.shared.currentPublicKey,
|
||||
groupDialogKey: route.publicKey
|
||||
), !meta.title.isEmpty {
|
||||
return meta.title
|
||||
}
|
||||
}
|
||||
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
|
||||
if !route.title.isEmpty { return route.title }
|
||||
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
|
||||
@@ -1091,15 +1176,15 @@ private extension ChatDetailView {
|
||||
func openProfile() {
|
||||
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
||||
isInputFocused = false
|
||||
// Force-dismiss keyboard at UIKit level immediately.
|
||||
// On iOS 26+, the async resignFirstResponder via syncFocus races with
|
||||
// the navigation transition — the system may re-focus the text view
|
||||
// when returning from the profile, causing a ghost keyboard.
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil
|
||||
)
|
||||
if route.isGroup {
|
||||
showGroupInfo = true
|
||||
} else {
|
||||
showOpponentProfile = true
|
||||
}
|
||||
}
|
||||
|
||||
func trailingAction() {
|
||||
if canSend { sendCurrentMessage() }
|
||||
|
||||
@@ -12,6 +12,7 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
|
||||
@Published private(set) var messages: [ChatMessage] = []
|
||||
@Published private(set) var isTyping: Bool = false
|
||||
@Published private(set) var typingSenderNames: [String] = []
|
||||
/// Android parity: true while loading messages from DB. Shows skeleton placeholder.
|
||||
@Published private(set) var isLoading: Bool = true
|
||||
/// Pagination: true while older messages are available in SQLite.
|
||||
@@ -90,16 +91,19 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Subscribe to typing state changes, filtered to our dialog
|
||||
let typingPublisher = repo.$typingDialogs
|
||||
.map { (dialogs: Set<String>) -> Bool in
|
||||
dialogs.contains(key)
|
||||
repo.$typingDialogs
|
||||
.map { (dialogs: [String: Set<String>]) -> [String] in
|
||||
guard let senderKeys = dialogs[key], !senderKeys.isEmpty else { return [] }
|
||||
return senderKeys.map { sk in
|
||||
DialogRepository.shared.dialogs[sk]?.opponentTitle
|
||||
?? String(sk.prefix(8))
|
||||
}
|
||||
}
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
||||
typingPublisher
|
||||
.sink { [weak self] typing in
|
||||
self?.isTyping = typing
|
||||
.sink { [weak self] names in
|
||||
self?.isTyping = !names.isEmpty
|
||||
self?.typingSenderNames = names
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@@ -14,4 +14,5 @@ final class MessageCellActions {
|
||||
var onRetry: (ChatMessage) -> Void = { _ in }
|
||||
var onRemove: (ChatMessage) -> Void = { _ in }
|
||||
var onCall: (String) -> Void = { _ in } // peer public key
|
||||
var onGroupInviteTap: (String) -> Void = { _ in } // invite string
|
||||
}
|
||||
|
||||
@@ -140,6 +140,12 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
private let forwardAvatarImageView = UIImageView()
|
||||
private let forwardNameLabel = UILabel()
|
||||
|
||||
// Group sender info (Telegram parity)
|
||||
private let senderNameLabel = UILabel()
|
||||
private let senderAvatarContainer = UIView()
|
||||
private let senderAvatarImageView = UIImageView()
|
||||
private let senderAvatarInitialLabel = UILabel()
|
||||
|
||||
// Highlight overlay (scroll-to-message flash)
|
||||
private let highlightOverlay = UIView()
|
||||
|
||||
@@ -419,6 +425,25 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
forwardNameLabel.textColor = .white // default, overridden in configure()
|
||||
bubbleView.addSubview(forwardNameLabel)
|
||||
|
||||
// Group sender info (Telegram parity: name INSIDE bubble as first line, avatar left)
|
||||
senderNameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
senderNameLabel.isHidden = true
|
||||
bubbleView.addSubview(senderNameLabel) // INSIDE bubble (Telegram: name is first line in bubble)
|
||||
|
||||
senderAvatarContainer.layer.cornerRadius = 18 // 36pt circle
|
||||
senderAvatarContainer.clipsToBounds = true
|
||||
senderAvatarContainer.isHidden = true
|
||||
contentView.addSubview(senderAvatarContainer)
|
||||
|
||||
senderAvatarInitialLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||
senderAvatarInitialLabel.textColor = .white
|
||||
senderAvatarInitialLabel.textAlignment = .center
|
||||
senderAvatarContainer.addSubview(senderAvatarInitialLabel)
|
||||
|
||||
senderAvatarImageView.contentMode = .scaleAspectFill
|
||||
senderAvatarImageView.clipsToBounds = true
|
||||
senderAvatarContainer.addSubview(senderAvatarImageView)
|
||||
|
||||
// Highlight overlay — on top of all bubble content
|
||||
highlightOverlay.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||
highlightOverlay.isUserInteractionEnabled = false
|
||||
@@ -764,9 +789,15 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
dateHeaderContainer.isHidden = true
|
||||
|
||||
// Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment
|
||||
// Group incoming: offset right by 40pt for avatar lane (Telegram parity).
|
||||
let isGroupIncoming = !layout.isOutgoing && layout.senderKey.count > 0
|
||||
let groupAvatarLane: CGFloat = isGroupIncoming ? 38 : 0
|
||||
let bubbleX: CGFloat
|
||||
if layout.isOutgoing {
|
||||
bubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset
|
||||
} else if isGroupIncoming {
|
||||
// Group: avatar lane replaces tail space. Avatar at 4pt, bubble after lane.
|
||||
bubbleX = 4 + groupAvatarLane
|
||||
} else {
|
||||
bubbleX = tailProtrusion + 2
|
||||
}
|
||||
@@ -981,6 +1012,103 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2
|
||||
replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter)
|
||||
replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20)
|
||||
|
||||
// Group sender name — INSIDE bubble as first line (Telegram parity).
|
||||
// When shown, shift all bubble content (text, reply, etc.) down by senderNameShift.
|
||||
let senderNameShift: CGFloat = layout.showsSenderName ? 20 : 0
|
||||
if layout.showsSenderName {
|
||||
senderNameLabel.isHidden = false
|
||||
senderNameLabel.text = layout.senderName
|
||||
let nameColorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey)
|
||||
senderNameLabel.textColor = RosettaColors.avatarTextColor(for: nameColorIdx)
|
||||
senderNameLabel.sizeToFit()
|
||||
// Position inside bubble: top-left, with standard bubble padding
|
||||
senderNameLabel.frame = CGRect(
|
||||
x: 10,
|
||||
y: 6,
|
||||
width: min(senderNameLabel.bounds.width, bubbleView.bounds.width - 24),
|
||||
height: 16
|
||||
)
|
||||
// Expand bubble to fit the name
|
||||
bubbleView.frame.size.height += senderNameShift
|
||||
// Shift text and other content down to make room for the name
|
||||
textLabel.frame.origin.y += senderNameShift
|
||||
timestampLabel.frame.origin.y += senderNameShift
|
||||
checkSentView.frame.origin.y += senderNameShift
|
||||
checkReadView.frame.origin.y += senderNameShift
|
||||
clockFrameView.frame.origin.y += senderNameShift
|
||||
clockMinView.frame.origin.y += senderNameShift
|
||||
statusBackgroundView.frame.origin.y += senderNameShift
|
||||
if layout.hasReplyQuote {
|
||||
replyContainer.frame.origin.y += senderNameShift
|
||||
}
|
||||
if layout.hasPhoto {
|
||||
photoContainer.frame.origin.y += senderNameShift
|
||||
}
|
||||
if layout.hasFile {
|
||||
fileContainer.frame.origin.y += senderNameShift
|
||||
}
|
||||
if layout.isForward {
|
||||
forwardLabel.frame.origin.y += senderNameShift
|
||||
forwardAvatarView.frame.origin.y += senderNameShift
|
||||
forwardNameLabel.frame.origin.y += senderNameShift
|
||||
}
|
||||
// Re-apply bubble image with tail protrusion after height expansion
|
||||
let expandedImageFrame: CGRect
|
||||
if layout.isOutgoing {
|
||||
expandedImageFrame = CGRect(x: 0, y: 0,
|
||||
width: bubbleView.bounds.width + tailProtrusion,
|
||||
height: bubbleView.bounds.height)
|
||||
} else {
|
||||
expandedImageFrame = CGRect(x: -tailProtrusion, y: 0,
|
||||
width: bubbleView.bounds.width + tailProtrusion,
|
||||
height: bubbleView.bounds.height)
|
||||
}
|
||||
bubbleImageView.frame = expandedImageFrame.insetBy(dx: -1, dy: -1)
|
||||
highlightOverlay.frame = bubbleView.bounds
|
||||
// Update shadow/outline layers for expanded height
|
||||
bubbleLayer.frame = bubbleView.bounds
|
||||
bubbleLayer.path = BubblePathCache.shared.path(
|
||||
size: expandedImageFrame.size, origin: expandedImageFrame.origin,
|
||||
mergeType: layout.mergeType,
|
||||
isOutgoing: layout.isOutgoing,
|
||||
metrics: Self.bubbleMetrics
|
||||
)
|
||||
bubbleLayer.shadowPath = bubbleLayer.path
|
||||
bubbleOutlineLayer.frame = bubbleView.bounds
|
||||
bubbleOutlineLayer.path = bubbleLayer.path
|
||||
} else {
|
||||
senderNameLabel.isHidden = true
|
||||
}
|
||||
|
||||
// Group sender avatar (left of bubble, last in run, Telegram parity)
|
||||
// Size: 36pt (Android 42dp, Desktop 40px — average ~36pt on iOS)
|
||||
if layout.showsSenderAvatar {
|
||||
let avatarSize: CGFloat = 36
|
||||
senderAvatarContainer.isHidden = false
|
||||
senderAvatarContainer.frame = CGRect(
|
||||
x: 4,
|
||||
y: bubbleView.frame.maxY - avatarSize,
|
||||
width: avatarSize,
|
||||
height: avatarSize
|
||||
)
|
||||
senderAvatarInitialLabel.frame = senderAvatarContainer.bounds
|
||||
senderAvatarImageView.frame = senderAvatarContainer.bounds
|
||||
senderAvatarImageView.layer.cornerRadius = avatarSize / 2
|
||||
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey)
|
||||
senderAvatarContainer.backgroundColor = RosettaColors.avatarColor(for: colorIdx)
|
||||
senderAvatarInitialLabel.text = RosettaColors.initials(name: layout.senderName, publicKey: layout.senderKey)
|
||||
if let image = AvatarRepository.shared.loadAvatar(publicKey: layout.senderKey) {
|
||||
senderAvatarImageView.image = image
|
||||
senderAvatarImageView.isHidden = false
|
||||
} else {
|
||||
senderAvatarImageView.image = nil
|
||||
senderAvatarImageView.isHidden = true
|
||||
}
|
||||
} else {
|
||||
senderAvatarContainer.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
private static func formattedDuration(seconds: Int) -> String {
|
||||
@@ -1042,18 +1170,49 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
/// Tries each password candidate to decrypt avatar image data.
|
||||
private static func decryptAvatarImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
||||
let crypto = CryptoManager.shared
|
||||
#if DEBUG
|
||||
print("[AVATAR-DBG] decryptAvatarImage blob=\(encryptedString.count) chars, \(passwords.count) candidates")
|
||||
#endif
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password, requireCompression: true
|
||||
) else { continue }
|
||||
) else {
|
||||
#if DEBUG
|
||||
print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → nil")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
#if DEBUG
|
||||
let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined()
|
||||
let utf8 = String(data: data.prefix(60), encoding: .utf8) ?? "<not utf8>"
|
||||
print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → \(data.count) bytes, hex=\(hex), utf8=\(utf8.prefix(60))")
|
||||
#endif
|
||||
if let img = parseAvatarImageData(data) { return img }
|
||||
#if DEBUG
|
||||
print("[AVATAR-DBG] parseAvatarImageData returned nil for requireCompression=true data")
|
||||
#endif
|
||||
}
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
) else { continue }
|
||||
if let img = parseAvatarImageData(data) { return img }
|
||||
) else {
|
||||
#if DEBUG
|
||||
print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → nil")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
#if DEBUG
|
||||
let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined()
|
||||
print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → \(data.count) bytes, hex=\(hex)")
|
||||
#endif
|
||||
if let img = parseAvatarImageData(data) { return img }
|
||||
#if DEBUG
|
||||
print("[AVATAR-DBG] parseAvatarImageData returned nil for noCompression data")
|
||||
#endif
|
||||
}
|
||||
#if DEBUG
|
||||
print("[AVATAR-DBG] ❌ All candidates failed")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1231,16 +1390,26 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return }
|
||||
let tag = avatarAtt.effectiveDownloadTag
|
||||
guard !tag.isEmpty else { return }
|
||||
guard let password = message.attachmentPassword, !password.isEmpty else { return }
|
||||
guard let password = message.attachmentPassword, !password.isEmpty else {
|
||||
#if DEBUG
|
||||
print("[AVATAR-DBG] ❌ No attachmentPassword for avatar id=\(id)")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("[AVATAR-DBG] Starting download tag=\(tag.prefix(12))… pwd=\(password.prefix(8))… len=\(password.count)")
|
||||
#endif
|
||||
fileSizeLabel.text = "Downloading..."
|
||||
let messageId = message.id
|
||||
let senderKey = message.fromPublicKey
|
||||
// Desktop parity: group avatar saves to group dialog key, not sender key
|
||||
let avatarTargetKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
|
||||
? message.toPublicKey : message.fromPublicKey
|
||||
let server = avatarAtt.transportServer
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let downloaded = await Self.downloadAndCacheAvatar(
|
||||
tag: tag, attachmentId: id,
|
||||
storedPassword: password, senderKey: senderKey,
|
||||
storedPassword: password, senderKey: avatarTargetKey,
|
||||
server: server
|
||||
)
|
||||
await MainActor.run { [weak self] in
|
||||
@@ -1250,6 +1419,10 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
self.avatarImageView.isHidden = false
|
||||
self.fileIconView.isHidden = true
|
||||
self.fileSizeLabel.text = "Shared profile photo"
|
||||
// Trigger refresh of sender avatar circles in visible cells
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.Name("avatarDidUpdate"), object: nil
|
||||
)
|
||||
} else {
|
||||
self.fileSizeLabel.text = "Tap to retry"
|
||||
}
|
||||
@@ -1815,6 +1988,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) {
|
||||
if photoDownloadTasks[attachment.id] != nil { return }
|
||||
let tag = attachment.effectiveDownloadTag
|
||||
#if DEBUG
|
||||
print("[PHOTO-DBG] downloadPhoto tag=\(tag.prefix(12))… pwd=\(message.attachmentPassword?.prefix(8) ?? "nil") len=\(message.attachmentPassword?.count ?? 0)")
|
||||
#endif
|
||||
guard !tag.isEmpty,
|
||||
let storedPassword = message.attachmentPassword,
|
||||
!storedPassword.isEmpty else {
|
||||
@@ -2135,6 +2311,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
forwardLabel.isHidden = true
|
||||
forwardAvatarView.isHidden = true
|
||||
forwardNameLabel.isHidden = true
|
||||
senderNameLabel.isHidden = true
|
||||
senderAvatarContainer.isHidden = true
|
||||
senderAvatarImageView.image = nil
|
||||
photoContainer.isHidden = true
|
||||
bubbleView.transform = .identity
|
||||
replyCircleView.alpha = 0
|
||||
|
||||
@@ -30,6 +30,7 @@ final class NativeMessageListController: UIViewController {
|
||||
var highlightedMessageId: String?
|
||||
var isSavedMessages: Bool
|
||||
var isSystemAccount: Bool
|
||||
var isGroupChat: Bool = false
|
||||
var opponentPublicKey: String
|
||||
var opponentTitle: String
|
||||
var opponentUsername: String
|
||||
@@ -60,6 +61,22 @@ final class NativeMessageListController: UIViewController {
|
||||
private(set) var messages: [ChatMessage] = []
|
||||
var hasMoreMessages: Bool = true
|
||||
|
||||
/// Cached group attachment password (hex-encoded group key, Desktop parity).
|
||||
private lazy var resolvedGroupKey: String? = {
|
||||
guard config.isGroupChat else { return nil }
|
||||
let session = SessionManager.shared
|
||||
guard let privKey = session.privateKeyHex else { return nil }
|
||||
let account = session.currentPublicKey ?? ""
|
||||
guard !account.isEmpty else { return nil }
|
||||
guard let gk = GroupRepository.shared.groupKey(
|
||||
account: account,
|
||||
privateKeyHex: privKey,
|
||||
groupDialogKey: config.opponentPublicKey
|
||||
) else { return nil }
|
||||
// Desktop parity: Buffer.from(groupKey).toString('hex')
|
||||
return Data(gk.utf8).map { String(format: "%02x", $0) }.joined()
|
||||
}()
|
||||
|
||||
// MARK: - UIKit
|
||||
|
||||
private var collectionView: UICollectionView!
|
||||
@@ -162,6 +179,14 @@ final class NativeMessageListController: UIViewController {
|
||||
self, selector: #selector(interactiveKeyboardHeightChanged(_:)),
|
||||
name: NSNotification.Name("InteractiveKeyboardHeightChanged"), object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleAvatarDidUpdate),
|
||||
name: Notification.Name("avatarDidUpdate"), object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func handleAvatarDidUpdate() {
|
||||
reconfigureVisibleCells()
|
||||
}
|
||||
|
||||
// Dismiss keyboard on swipe-back. Let keyboardWillChangeFrame update insets
|
||||
@@ -260,13 +285,22 @@ final class NativeMessageListController: UIViewController {
|
||||
[weak self] cell, indexPath, message in
|
||||
guard let self else { return }
|
||||
|
||||
// Patch group messages without attachment password (legacy messages before fix).
|
||||
var msg = message
|
||||
if self.config.isGroupChat,
|
||||
msg.attachmentPassword == nil,
|
||||
!msg.attachments.isEmpty,
|
||||
let gk = self.resolvedGroupKey {
|
||||
msg.attachmentPassword = gk
|
||||
}
|
||||
|
||||
// Apply pre-calculated layout (just sets frames — no computation)
|
||||
if let layout = self.layoutCache[message.id] {
|
||||
if let layout = self.layoutCache[msg.id] {
|
||||
cell.apply(layout: layout)
|
||||
}
|
||||
|
||||
// Parse reply data for quote display
|
||||
let replyAtt = message.attachments.first { $0.type == .messages }
|
||||
let replyAtt = msg.attachments.first { $0.type == .messages }
|
||||
var replyName: String?
|
||||
var replyText: String?
|
||||
var replyMessageId: String?
|
||||
@@ -290,7 +324,7 @@ final class NativeMessageListController: UIViewController {
|
||||
?? String(senderKey.prefix(8)) + "…"
|
||||
}
|
||||
|
||||
let displayText = MessageCellLayout.isGarbageOrEncrypted(message.text) ? "" : message.text
|
||||
let displayText = MessageCellLayout.isGarbageOrEncrypted(msg.text) ? "" : msg.text
|
||||
if displayText.isEmpty {
|
||||
// Forward
|
||||
forwardSenderName = name
|
||||
@@ -308,9 +342,9 @@ final class NativeMessageListController: UIViewController {
|
||||
cell.isSavedMessages = self.config.isSavedMessages
|
||||
cell.isSystemAccount = self.config.isSystemAccount
|
||||
cell.configure(
|
||||
message: message,
|
||||
timestamp: self.formatTimestamp(message.timestamp),
|
||||
textLayout: self.textLayoutCache[message.id],
|
||||
message: msg,
|
||||
timestamp: self.formatTimestamp(msg.timestamp),
|
||||
textLayout: self.textLayoutCache[msg.id],
|
||||
actions: self.config.actions,
|
||||
replyName: replyName,
|
||||
replyText: replyText,
|
||||
@@ -933,7 +967,8 @@ final class NativeMessageListController: UIViewController {
|
||||
maxBubbleWidth: config.maxBubbleWidth,
|
||||
currentPublicKey: config.currentPublicKey,
|
||||
opponentPublicKey: config.opponentPublicKey,
|
||||
opponentTitle: config.opponentTitle
|
||||
opponentTitle: config.opponentTitle,
|
||||
isGroupChat: config.isGroupChat
|
||||
)
|
||||
layoutCache = layouts
|
||||
textLayoutCache = textLayouts
|
||||
@@ -1322,6 +1357,7 @@ struct NativeMessageListView: UIViewControllerRepresentable {
|
||||
highlightedMessageId: highlightedMessageId,
|
||||
isSavedMessages: route.isSavedMessages,
|
||||
isSystemAccount: route.isSystemAccount,
|
||||
isGroupChat: route.isGroup,
|
||||
opponentPublicKey: route.publicKey,
|
||||
opponentTitle: route.title,
|
||||
opponentUsername: route.username,
|
||||
|
||||
@@ -33,6 +33,16 @@ enum TelegramContextMenuBuilder {
|
||||
))
|
||||
}
|
||||
|
||||
// Desktop parity: detect #group: invite strings and offer "Join Group" action.
|
||||
if message.text.hasPrefix("#group:") {
|
||||
items.append(TelegramContextMenuItem(
|
||||
title: "Join Group",
|
||||
iconName: "person.2.badge.plus",
|
||||
isDestructive: false,
|
||||
handler: { actions.onGroupInviteTap(message.text) }
|
||||
))
|
||||
}
|
||||
|
||||
if !message.text.isEmpty {
|
||||
items.append(TelegramContextMenuItem(
|
||||
title: "Copy",
|
||||
|
||||
@@ -32,6 +32,9 @@ struct ChatListView: View {
|
||||
@State private var searchText = ""
|
||||
@State private var hasPinnedChats = false
|
||||
@State private var showRequestChats = false
|
||||
@State private var showNewGroupSheet = false
|
||||
@State private var showJoinGroupSheet = false
|
||||
@State private var showNewChatActionSheet = false
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
@@ -103,6 +106,35 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
.tint(RosettaColors.figmaBlue)
|
||||
.confirmationDialog("New", isPresented: $showNewChatActionSheet) {
|
||||
Button("New Group") { showNewGroupSheet = true }
|
||||
Button("Join Group") { showJoinGroupSheet = true }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.sheet(isPresented: $showNewGroupSheet) {
|
||||
NavigationStack {
|
||||
GroupSetupView { route in
|
||||
showNewGroupSheet = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
navigationState.path = [route]
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.sheet(isPresented: $showJoinGroupSheet) {
|
||||
NavigationStack {
|
||||
GroupJoinView { route in
|
||||
showJoinGroupSheet = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
navigationState.path = [route]
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
|
||||
guard let route = notification.object as? ChatRoute else { return }
|
||||
// Navigate to the chat from push notification tap (fast path)
|
||||
@@ -307,7 +339,7 @@ private extension ChatListView {
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.accessibilityLabel("Camera")
|
||||
Button { } label: {
|
||||
Button { showNewChatActionSheet = true } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
@@ -350,7 +382,7 @@ private extension ChatListView {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Add chat")
|
||||
|
||||
Button { } label: {
|
||||
Button { showNewChatActionSheet = true } label: {
|
||||
Image("toolbar-compose")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
@@ -589,7 +621,7 @@ private struct ChatListDialogContent: View {
|
||||
var onPinnedStateChange: (Bool) -> Void = { _ in }
|
||||
|
||||
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
|
||||
@State private var typingDialogs: Set<String> = []
|
||||
@State private var typingDialogs: [String: Set<String>] = [:]
|
||||
|
||||
var body: some View {
|
||||
let _ = PerformanceLogger.shared.track("chatList.bodyEval")
|
||||
@@ -687,7 +719,14 @@ private struct ChatListDialogContent: View {
|
||||
/// stable class references don't trigger row re-evaluation on parent re-render.
|
||||
SyncAwareChatRow(
|
||||
dialog: dialog,
|
||||
isTyping: typingDialogs.contains(dialog.opponentKey),
|
||||
isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true),
|
||||
typingSenderNames: {
|
||||
guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] }
|
||||
return senderKeys.map { sk in
|
||||
DialogRepository.shared.dialogs[sk]?.opponentTitle
|
||||
?? String(sk.prefix(8))
|
||||
}
|
||||
}(),
|
||||
isFirst: isFirst,
|
||||
viewModel: viewModel,
|
||||
navigationState: navigationState
|
||||
@@ -713,6 +752,7 @@ private struct ChatListDialogContent: View {
|
||||
struct SyncAwareChatRow: View {
|
||||
let dialog: Dialog
|
||||
let isTyping: Bool
|
||||
let typingSenderNames: [String]
|
||||
let isFirst: Bool
|
||||
let viewModel: ChatListViewModel
|
||||
let navigationState: ChatListNavigationState
|
||||
@@ -725,7 +765,8 @@ struct SyncAwareChatRow: View {
|
||||
ChatRowView(
|
||||
dialog: dialog,
|
||||
isSyncing: isSyncing,
|
||||
isTyping: isTyping
|
||||
isTyping: isTyping,
|
||||
typingSenderNames: typingSenderNames
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -24,10 +24,19 @@ struct ChatRowView: View {
|
||||
var isSyncing: Bool = false
|
||||
/// Desktop parity: show "typing..." instead of last message.
|
||||
var isTyping: Bool = false
|
||||
/// Group typing: sender names for "Name typing..." / "Name and N typing..." display.
|
||||
var typingSenderNames: [String] = []
|
||||
|
||||
|
||||
var displayTitle: String {
|
||||
if dialog.isSavedMessages { return "Saved Messages" }
|
||||
if dialog.isGroup {
|
||||
let meta = GroupRepository.shared.groupMetadata(
|
||||
account: dialog.account,
|
||||
groupDialogKey: dialog.opponentKey
|
||||
)
|
||||
if let title = meta?.title, !title.isEmpty { return title }
|
||||
}
|
||||
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
|
||||
if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
|
||||
return String(dialog.opponentKey.prefix(12))
|
||||
@@ -57,9 +66,17 @@ private struct ChatRowAvatar: View {
|
||||
let dialog: Dialog
|
||||
|
||||
var body: some View {
|
||||
if dialog.isGroup {
|
||||
groupAvatarView
|
||||
} else {
|
||||
directAvatarView
|
||||
}
|
||||
}
|
||||
|
||||
private var directAvatarView: some View {
|
||||
// Establish @Observable tracking — re-renders this view on avatar save/remove.
|
||||
let _ = AvatarRepository.shared.avatarVersion
|
||||
AvatarView(
|
||||
return AvatarView(
|
||||
initials: dialog.initials,
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
size: 62,
|
||||
@@ -68,6 +85,27 @@ private struct ChatRowAvatar: View {
|
||||
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
)
|
||||
}
|
||||
|
||||
private var groupAvatarView: some View {
|
||||
let _ = AvatarRepository.shared.avatarVersion
|
||||
let groupImage = AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
return ZStack {
|
||||
if let image = groupImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 62, height: 62)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Circle()
|
||||
.fill(RosettaColors.avatarColors[dialog.avatarColorIndex % RosettaColors.avatarColors.count].tint)
|
||||
.frame(width: 62, height: 62)
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 24, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatRowView {
|
||||
@@ -152,12 +190,23 @@ private extension ChatRowView {
|
||||
var messageText: String {
|
||||
// Desktop parity: show "typing..." in chat list row when opponent is typing.
|
||||
if isTyping && !dialog.isSavedMessages {
|
||||
if dialog.isGroup && !typingSenderNames.isEmpty {
|
||||
if typingSenderNames.count == 1 {
|
||||
return "\(typingSenderNames[0]) typing..."
|
||||
} else {
|
||||
return "\(typingSenderNames[0]) and \(typingSenderNames.count - 1) typing..."
|
||||
}
|
||||
}
|
||||
return "typing..."
|
||||
}
|
||||
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty {
|
||||
return "No messages yet"
|
||||
}
|
||||
// Desktop parity: show "Group invite" for #group: invite messages.
|
||||
if raw.hasPrefix("#group:") {
|
||||
return "Group invite"
|
||||
}
|
||||
// Safety net: never show encrypted ciphertext (ivBase64:ctBase64) to user.
|
||||
// This catches stale data persisted before isGarbageText was improved.
|
||||
if Self.looksLikeCiphertext(raw) {
|
||||
|
||||
@@ -9,7 +9,7 @@ struct RequestChatsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
|
||||
@State private var typingDialogs: Set<String> = []
|
||||
@State private var typingDialogs: [String: Set<String>] = [:]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -79,7 +79,14 @@ struct RequestChatsView: View {
|
||||
private func requestRow(_ dialog: Dialog, isFirst: Bool) -> some View {
|
||||
SyncAwareChatRow(
|
||||
dialog: dialog,
|
||||
isTyping: typingDialogs.contains(dialog.opponentKey),
|
||||
isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true),
|
||||
typingSenderNames: {
|
||||
guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] }
|
||||
return senderKeys.map { sk in
|
||||
DialogRepository.shared.dialogs[sk]?.opponentTitle
|
||||
?? String(sk.prefix(8))
|
||||
}
|
||||
}(),
|
||||
isFirst: isFirst,
|
||||
viewModel: viewModel,
|
||||
navigationState: navigationState
|
||||
|
||||
@@ -41,10 +41,23 @@ struct ChatRoute: Hashable {
|
||||
)
|
||||
}
|
||||
|
||||
init(groupDialogKey: String, title: String, description: String = "") {
|
||||
self.init(
|
||||
publicKey: groupDialogKey,
|
||||
title: title,
|
||||
username: description,
|
||||
verified: 0
|
||||
)
|
||||
}
|
||||
|
||||
var isSavedMessages: Bool {
|
||||
publicKey == SessionManager.shared.currentPublicKey
|
||||
}
|
||||
|
||||
var isGroup: Bool {
|
||||
DatabaseManager.isGroupDialogKey(publicKey)
|
||||
}
|
||||
|
||||
var isSystemAccount: Bool {
|
||||
SystemAccounts.isSystemAccount(publicKey)
|
||||
}
|
||||
|
||||
235
Rosetta/Features/Groups/GroupInfoView.swift
Normal file
235
Rosetta/Features/Groups/GroupInfoView.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - GroupInfoView
|
||||
|
||||
/// Telegram-style group detail screen: header, members, invite link, leave.
|
||||
struct GroupInfoView: View {
|
||||
@StateObject private var viewModel: GroupInfoViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showLeaveAlert = false
|
||||
@State private var memberToKick: GroupMember?
|
||||
|
||||
init(groupDialogKey: String) {
|
||||
_viewModel = StateObject(wrappedValue: GroupInfoViewModel(groupDialogKey: groupDialogKey))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RosettaColors.Dark.background.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
headerSection
|
||||
if !viewModel.groupDescription.isEmpty {
|
||||
descriptionSection
|
||||
}
|
||||
inviteLinkSection
|
||||
membersSection
|
||||
leaveSection
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
if viewModel.isLeaving {
|
||||
Color.black.opacity(0.5)
|
||||
.ignoresSafeArea()
|
||||
.overlay {
|
||||
ProgressView().tint(.white).scaleEffect(1.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Group Info")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
.task { await viewModel.loadMembers() }
|
||||
.onChange(of: viewModel.didLeaveGroup) { left in
|
||||
if left { dismiss() }
|
||||
}
|
||||
.alert("Leave Group", isPresented: $showLeaveAlert) {
|
||||
Button("Leave", role: .destructive) {
|
||||
Task { await viewModel.leaveGroup() }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Are you sure you want to leave this group? You will lose access to all messages.")
|
||||
}
|
||||
.alert("Remove Member", isPresented: .init(
|
||||
get: { memberToKick != nil },
|
||||
set: { if !$0 { memberToKick = nil } }
|
||||
)) {
|
||||
Button("Remove", role: .destructive) {
|
||||
if let member = memberToKick {
|
||||
Task { await viewModel.kickMember(publicKey: member.id) }
|
||||
}
|
||||
memberToKick = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { memberToKick = nil }
|
||||
} message: {
|
||||
if let member = memberToKick {
|
||||
Text("Remove \(member.title) from this group?")
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { viewModel.errorMessage != nil },
|
||||
set: { if !$0 { viewModel.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
if let msg = viewModel.errorMessage { Text(msg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private extension GroupInfoView {
|
||||
var headerSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Group avatar
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(RosettaColors.figmaBlue.opacity(0.2))
|
||||
.frame(width: 90, height: 90)
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
|
||||
Text(viewModel.groupTitle)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Text("\(viewModel.members.count) members")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
var descriptionSection: some View {
|
||||
GlassCard(cornerRadius: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Description")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
Text(viewModel.groupDescription)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
var inviteLinkSection: some View {
|
||||
GlassCard(cornerRadius: 16) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Invite Link")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
|
||||
if let invite = viewModel.inviteString {
|
||||
Text(invite)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
UIPasteboard.general.string = invite
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
viewModel.generateInvite()
|
||||
} label: {
|
||||
Label("Generate Link", systemImage: "link")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
var membersSection: some View {
|
||||
GlassCard(cornerRadius: 16) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Members (\(viewModel.members.count))")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if viewModel.isLoading {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView().tint(.white)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
} else {
|
||||
ForEach(viewModel.members) { member in
|
||||
memberRow(member)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func memberRow(_ member: GroupMember) -> some View {
|
||||
let myKey = SessionManager.shared.currentPublicKey
|
||||
let canKick = viewModel.isAdmin && member.id != myKey && !member.isAdmin
|
||||
|
||||
GroupMemberRow(member: member)
|
||||
.padding(.horizontal, 16)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
if canKick {
|
||||
Button(role: .destructive) {
|
||||
memberToKick = member
|
||||
} label: {
|
||||
Label("Remove", systemImage: "person.badge.minus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var leaveSection: some View {
|
||||
Button {
|
||||
showLeaveAlert = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
Text("Leave Group")
|
||||
}
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(.red)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background {
|
||||
GlassCard(cornerRadius: 16) {
|
||||
Color.clear.frame(height: 50)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
96
Rosetta/Features/Groups/GroupInfoViewModel.swift
Normal file
96
Rosetta/Features/Groups/GroupInfoViewModel.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
// MARK: - GroupMember
|
||||
|
||||
struct GroupMember: Identifiable {
|
||||
let id: String // publicKey
|
||||
var title: String
|
||||
var username: String
|
||||
var isAdmin: Bool
|
||||
var isOnline: Bool
|
||||
var verified: Int
|
||||
}
|
||||
|
||||
// MARK: - GroupInfoViewModel
|
||||
|
||||
@MainActor
|
||||
final class GroupInfoViewModel: ObservableObject {
|
||||
let groupDialogKey: String
|
||||
|
||||
@Published var groupTitle: String = ""
|
||||
@Published var groupDescription: String = ""
|
||||
@Published var members: [GroupMember] = []
|
||||
@Published var isAdmin: Bool = false
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var isLeaving: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var didLeaveGroup: Bool = false
|
||||
@Published var inviteString: String?
|
||||
|
||||
private let groupService = GroupService.shared
|
||||
private let groupRepo = GroupRepository.shared
|
||||
|
||||
init(groupDialogKey: String) {
|
||||
self.groupDialogKey = groupDialogKey
|
||||
loadLocalMetadata()
|
||||
}
|
||||
|
||||
private func loadLocalMetadata() {
|
||||
let account = SessionManager.shared.currentPublicKey
|
||||
if let meta = groupRepo.groupMetadata(account: account, groupDialogKey: groupDialogKey) {
|
||||
groupTitle = meta.title
|
||||
groupDescription = meta.description
|
||||
}
|
||||
}
|
||||
|
||||
func loadMembers() async {
|
||||
isLoading = true
|
||||
do {
|
||||
let memberKeys = try await groupService.requestMembers(groupDialogKey: groupDialogKey)
|
||||
let myKey = SessionManager.shared.currentPublicKey
|
||||
|
||||
var resolved: [GroupMember] = []
|
||||
for (index, key) in memberKeys.enumerated() {
|
||||
let dialog = DialogRepository.shared.dialogs[key]
|
||||
resolved.append(GroupMember(
|
||||
id: key,
|
||||
title: dialog?.opponentTitle ?? String(key.prefix(12)),
|
||||
username: dialog?.opponentUsername ?? "",
|
||||
isAdmin: index == 0,
|
||||
isOnline: dialog?.isOnline ?? false,
|
||||
verified: dialog?.verified ?? 0
|
||||
))
|
||||
}
|
||||
members = resolved
|
||||
isAdmin = memberKeys.first == myKey
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func leaveGroup() async {
|
||||
isLeaving = true
|
||||
do {
|
||||
try await groupService.leaveGroup(groupDialogKey: groupDialogKey)
|
||||
didLeaveGroup = true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isLeaving = false
|
||||
}
|
||||
|
||||
func kickMember(publicKey: String) async {
|
||||
do {
|
||||
try await groupService.kickMember(groupDialogKey: groupDialogKey, memberPublicKey: publicKey)
|
||||
members.removeAll { $0.id == publicKey }
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func generateInvite() {
|
||||
inviteString = groupService.generateInviteString(groupDialogKey: groupDialogKey)
|
||||
}
|
||||
}
|
||||
210
Rosetta/Features/Groups/GroupJoinView.swift
Normal file
210
Rosetta/Features/Groups/GroupJoinView.swift
Normal file
@@ -0,0 +1,210 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - GroupJoinView
|
||||
|
||||
/// Join a group by pasting an invite string.
|
||||
struct GroupJoinView: View {
|
||||
@StateObject private var viewModel = GroupJoinViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var onGroupJoined: ((ChatRoute) -> Void)?
|
||||
|
||||
@FocusState private var isFieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RosettaColors.Dark.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 24) {
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
// Invite string input
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Invite Link")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
|
||||
TextField("Paste invite link", text: $viewModel.inviteString)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.focused($isFieldFocused)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.onChange(of: viewModel.inviteString) { _ in
|
||||
viewModel.parseInvite()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background {
|
||||
GlassCard(cornerRadius: 12) {
|
||||
Color.clear.frame(height: 44)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Enter the group invite link shared with you.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
// Preview card (shown after parsing)
|
||||
if viewModel.hasParsedInvite {
|
||||
previewCard
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Join button
|
||||
if viewModel.hasParsedInvite {
|
||||
joinButton
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isJoining {
|
||||
Color.black.opacity(0.5)
|
||||
.ignoresSafeArea()
|
||||
.overlay {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(1.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Join Group")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
.onAppear { isFieldFocused = true }
|
||||
.onChange(of: viewModel.joinedRoute) { route in
|
||||
if let route {
|
||||
onGroupJoined?(route)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { viewModel.errorMessage != nil },
|
||||
set: { if !$0 { viewModel.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
if let msg = viewModel.errorMessage {
|
||||
Text(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Card
|
||||
|
||||
private extension GroupJoinView {
|
||||
var previewCard: some View {
|
||||
GlassCard(cornerRadius: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
// Group icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(RosettaColors.figmaBlue.opacity(0.2))
|
||||
.frame(width: 48, height: 48)
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let title = viewModel.parsedTitle {
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
if let count = viewModel.memberCount {
|
||||
Text("\(count) members")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.isChecking {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
|
||||
if let desc = viewModel.parsedDescription, !desc.isEmpty {
|
||||
Text(desc)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
|
||||
if let status = viewModel.inviteStatus {
|
||||
statusBadge(status)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.task { await viewModel.checkStatus() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func statusBadge(_ status: GroupStatus) -> some View {
|
||||
switch status {
|
||||
case .joined:
|
||||
Label("Already a member", systemImage: "checkmark.circle.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.green)
|
||||
case .banned:
|
||||
Label("You are banned", systemImage: "xmark.circle.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.red)
|
||||
case .invalid:
|
||||
Label("Group not found", systemImage: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.orange)
|
||||
case .notJoined:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Join Button
|
||||
|
||||
private extension GroupJoinView {
|
||||
var joinButton: some View {
|
||||
Button {
|
||||
Task { await viewModel.joinGroup() }
|
||||
} label: {
|
||||
Text("Join Group")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(canJoin ? RosettaColors.figmaBlue : RosettaColors.figmaBlue.opacity(0.4))
|
||||
)
|
||||
}
|
||||
.disabled(!canJoin)
|
||||
}
|
||||
|
||||
var canJoin: Bool {
|
||||
viewModel.hasParsedInvite
|
||||
&& !viewModel.isJoining
|
||||
&& viewModel.inviteStatus != .banned
|
||||
&& viewModel.inviteStatus != .invalid
|
||||
}
|
||||
}
|
||||
86
Rosetta/Features/Groups/GroupJoinViewModel.swift
Normal file
86
Rosetta/Features/Groups/GroupJoinViewModel.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
// MARK: - GroupJoinViewModel
|
||||
|
||||
/// ViewModel for joining a group via invite string.
|
||||
@MainActor
|
||||
final class GroupJoinViewModel: ObservableObject {
|
||||
@Published var inviteString: String = ""
|
||||
@Published var isJoining: Bool = false
|
||||
@Published var isChecking: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var joinedRoute: ChatRoute?
|
||||
|
||||
/// Preview info after parsing invite.
|
||||
@Published var parsedTitle: String?
|
||||
@Published var parsedDescription: String?
|
||||
@Published var memberCount: Int?
|
||||
@Published var inviteStatus: GroupStatus?
|
||||
|
||||
var hasParsedInvite: Bool { parsedTitle != nil }
|
||||
|
||||
func parseInvite() {
|
||||
let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let parsed = GroupRepository.shared.parseInviteString(trimmed) else {
|
||||
parsedTitle = nil
|
||||
parsedDescription = nil
|
||||
memberCount = nil
|
||||
inviteStatus = nil
|
||||
// Show error if user typed something but it didn't parse.
|
||||
if !trimmed.isEmpty {
|
||||
errorMessage = "Invalid invite link"
|
||||
} else {
|
||||
errorMessage = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
errorMessage = nil
|
||||
parsedTitle = parsed.title
|
||||
parsedDescription = parsed.description.isEmpty ? nil : parsed.description
|
||||
}
|
||||
|
||||
func checkStatus() async {
|
||||
guard let parsed = GroupRepository.shared.parseInviteString(
|
||||
inviteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
) else { return }
|
||||
|
||||
isChecking = true
|
||||
do {
|
||||
let result = try await GroupService.shared.checkInviteStatus(groupId: parsed.groupId)
|
||||
memberCount = result.memberCount
|
||||
inviteStatus = result.status
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
isChecking = false
|
||||
}
|
||||
|
||||
func joinGroup() async {
|
||||
let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
isJoining = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let route = try await GroupService.shared.joinGroup(inviteString: trimmed)
|
||||
joinedRoute = route
|
||||
} catch let error as GroupService.GroupError {
|
||||
switch error {
|
||||
case .joinRejected(let status):
|
||||
switch status {
|
||||
case .banned: errorMessage = "You are banned from this group"
|
||||
case .invalid: errorMessage = "This group no longer exists"
|
||||
default: errorMessage = error.localizedDescription
|
||||
}
|
||||
default:
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isJoining = false
|
||||
}
|
||||
}
|
||||
58
Rosetta/Features/Groups/GroupMemberRow.swift
Normal file
58
Rosetta/Features/Groups/GroupMemberRow.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - GroupMemberRow
|
||||
|
||||
/// Reusable row for displaying a group member with avatar, name, role badge.
|
||||
struct GroupMemberRow: View {
|
||||
let member: GroupMember
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Avatar
|
||||
AvatarView(
|
||||
initials: RosettaColors.initials(name: member.title, publicKey: member.id),
|
||||
colorIndex: RosettaColors.avatarColorIndex(for: member.title, publicKey: member.id),
|
||||
size: 44,
|
||||
isOnline: member.isOnline,
|
||||
isSavedMessages: false,
|
||||
image: AvatarRepository.shared.loadAvatar(publicKey: member.id)
|
||||
)
|
||||
|
||||
// Name + username
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text(member.title)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
if member.isAdmin {
|
||||
Text("admin")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
Capsule().fill(RosettaColors.figmaBlue)
|
||||
)
|
||||
}
|
||||
|
||||
if member.verified > 0 {
|
||||
VerifiedBadge(verified: member.verified, size: 14)
|
||||
}
|
||||
}
|
||||
|
||||
if !member.username.isEmpty {
|
||||
Text("@\(member.username)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
214
Rosetta/Features/Groups/GroupSetupView.swift
Normal file
214
Rosetta/Features/Groups/GroupSetupView.swift
Normal file
@@ -0,0 +1,214 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - GroupSetupView
|
||||
|
||||
/// Two-step Telegram-style group creation form.
|
||||
/// Step 1: Group name (required). Step 2: Description (optional) + Create.
|
||||
struct GroupSetupView: View {
|
||||
@StateObject private var viewModel = GroupSetupViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Called when group is created — passes the ChatRoute for navigation.
|
||||
var onGroupCreated: ((ChatRoute) -> Void)?
|
||||
|
||||
@State private var step: SetupStep = .name
|
||||
@FocusState private var isTitleFocused: Bool
|
||||
|
||||
private enum SetupStep {
|
||||
case name
|
||||
case description
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RosettaColors.Dark.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
stepContent
|
||||
}
|
||||
|
||||
if viewModel.isCreating {
|
||||
loadingOverlay
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbarContent }
|
||||
.onChange(of: viewModel.createdRoute) { route in
|
||||
if let route {
|
||||
onGroupCreated?(route)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { viewModel.errorMessage != nil },
|
||||
set: { if !$0 { viewModel.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
if let msg = viewModel.errorMessage {
|
||||
Text(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step Content
|
||||
|
||||
private extension GroupSetupView {
|
||||
@ViewBuilder
|
||||
var stepContent: some View {
|
||||
switch step {
|
||||
case .name:
|
||||
nameStep
|
||||
case .description:
|
||||
descriptionStep
|
||||
}
|
||||
}
|
||||
|
||||
var nameStep: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
// Group avatar placeholder
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(RosettaColors.figmaBlue.opacity(0.2))
|
||||
.frame(width: 80, height: 80)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
|
||||
// Group name field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Group Name")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
|
||||
TextField("Enter group name", text: $viewModel.title)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.focused($isTitleFocused)
|
||||
.onChange(of: viewModel.title) { newValue in
|
||||
if newValue.count > GroupSetupViewModel.maxTitleLength {
|
||||
viewModel.title = String(newValue.prefix(GroupSetupViewModel.maxTitleLength))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background {
|
||||
GlassCard(cornerRadius: 12) {
|
||||
Color.clear.frame(height: 44)
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(viewModel.title.count)/\(GroupSetupViewModel.maxTitleLength)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.onAppear { isTitleFocused = true }
|
||||
}
|
||||
|
||||
var descriptionStep: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Description (optional)")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
|
||||
TextField("Enter group description", text: $viewModel.groupDescription, axis: .vertical)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(3...6)
|
||||
.onChange(of: viewModel.groupDescription) { newValue in
|
||||
if newValue.count > GroupSetupViewModel.maxDescriptionLength {
|
||||
viewModel.groupDescription = String(
|
||||
newValue.prefix(GroupSetupViewModel.maxDescriptionLength)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background {
|
||||
GlassCard(cornerRadius: 12) {
|
||||
Color.clear.frame(height: 80)
|
||||
}
|
||||
}
|
||||
|
||||
Text("You can provide an optional description for your group.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading Overlay
|
||||
|
||||
private extension GroupSetupView {
|
||||
var loadingOverlay: some View {
|
||||
Color.black.opacity(0.5)
|
||||
.ignoresSafeArea()
|
||||
.overlay {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(1.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
private extension GroupSetupView {
|
||||
@ToolbarContentBuilder
|
||||
var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
switch step {
|
||||
case .name: dismiss()
|
||||
case .description: step = .name
|
||||
}
|
||||
} label: {
|
||||
Text(step == .name ? "Cancel" : "Back")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text(step == .name ? "New Group" : "Description")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
switch step {
|
||||
case .name:
|
||||
step = .description
|
||||
case .description:
|
||||
Task { await viewModel.createGroup() }
|
||||
}
|
||||
} label: {
|
||||
Text(step == .name ? "Next" : "Create")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
viewModel.canCreate || step == .name
|
||||
? RosettaColors.figmaBlue
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
}
|
||||
.disabled(step == .name ? viewModel.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty : !viewModel.canCreate)
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Rosetta/Features/Groups/GroupSetupViewModel.swift
Normal file
42
Rosetta/Features/Groups/GroupSetupViewModel.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - GroupSetupViewModel
|
||||
|
||||
/// ViewModel for the two-step group creation flow.
|
||||
/// Matches Android `GroupSetupScreen.kt` behavior.
|
||||
@MainActor
|
||||
final class GroupSetupViewModel: ObservableObject {
|
||||
@Published var title: String = ""
|
||||
@Published var groupDescription: String = ""
|
||||
@Published var isCreating: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var createdRoute: ChatRoute?
|
||||
|
||||
/// Maximum character limits (Android parity).
|
||||
static let maxTitleLength = 80
|
||||
static let maxDescriptionLength = 400
|
||||
|
||||
var canCreate: Bool {
|
||||
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isCreating
|
||||
}
|
||||
|
||||
func createGroup() async {
|
||||
guard canCreate else { return }
|
||||
isCreating = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let route = try await GroupService.shared.createGroup(
|
||||
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
description: groupDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
createdRoute = route
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isCreating = false
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import CallKit
|
||||
import FirebaseCore
|
||||
import FirebaseCrashlytics
|
||||
import FirebaseMessaging
|
||||
@@ -567,8 +568,17 @@ extension AppDelegate: PKPushRegistryDelegate {
|
||||
|
||||
// If callerKey is empty/invalid, immediately end the orphaned call.
|
||||
// Apple still required us to call reportNewIncomingCall, but we can't
|
||||
// connect a call without a valid peer key.
|
||||
// connect a call without a valid peer key. Without this, the CallKit
|
||||
// call stays visible → user taps Accept → pendingCallKitAccept stuck
|
||||
// forever → app in broken state until force-quit.
|
||||
if callerKey.isEmpty || error != nil {
|
||||
Task { @MainActor in
|
||||
CallKitManager.shared.reportCallEndedByRemote(reason: .failed)
|
||||
// Clear stale accept flag — user may have tapped Accept
|
||||
// on the orphaned CallKit UI before it was dismissed.
|
||||
// Without this, the flag persists and auto-accepts the NEXT call.
|
||||
CallManager.shared.pendingCallKitAccept = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user