Групповые чаты: sender name/avatar в ячейках, multi-typer typing, фикс скачивания фото/аватарок и verified badge

This commit is contained in:
2026-04-03 18:04:41 +05:00
parent de0818fe69
commit da6b3d7c3f
35 changed files with 2728 additions and 386 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View 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()
}
}

View File

@@ -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.

View File

@@ -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 }
}

View 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)
}
}

View File

@@ -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 publicKeydeviceId 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()
}
}

View File

@@ -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 {

View File

@@ -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
}
}
}

View File

@@ -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()
}

View File

@@ -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:

View File

@@ -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)")
}
}

View 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 ?? ""
)
}
}

View File

@@ -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 }

View File

@@ -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()
}

View File

@@ -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() }

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)
}

View 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)
}
}

View 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)
}
}

View 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
}
}

View 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
}
}

View 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())
}
}

View 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)
}
}
}

View 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
}
}

View File

@@ -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
}