Онлайн-статусы, исправление навигации и UI чатов

- Реализован PacketOnlineSubscribe (0x04) для подписки на статус собеседника
- Онлайн-статус загружается из результатов поиска (PacketSearch) при каждом хэндшейке
- Toolbar capsule показывает online/offline/typing вместо @username
- Зелёная точка онлайн-индикатора на аватаре в списке чатов (bottom-left, как в Android)
- Убрана точка с аватара в toolbar (статус отображается текстом)
- Исправлен баг двойного тапа при входе в чат (программная навигация вместо NavigationLink)
- DialogRepository.updateUserInfo теперь принимает и сохраняет online-статус
- Очистка requestedUserInfoKeys при реконнекте для обновления статусов
- Добавлено логирование результатов поиска и отправки пакетов

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 04:00:57 +05:00
parent 003c262378
commit 6bef51e235
16 changed files with 769 additions and 405 deletions

View File

@@ -10,6 +10,7 @@ enum ConnectionState: String {
case connecting
case connected
case handshaking
case deviceVerificationRequired
case authenticated
}
@@ -27,6 +28,14 @@ final class ProtocolManager: @unchecked Sendable {
private(set) var connectionState: ConnectionState = .disconnected
// MARK: - Device Verification State
/// Device waiting for approval from this device (shown as banner on primary device).
private(set) var pendingDeviceVerification: DeviceEntry?
/// All connected devices.
private(set) var devices: [DeviceEntry] = []
// MARK: - Callbacks
var onMessageReceived: ((PacketMessage) -> Void)?
@@ -92,10 +101,13 @@ final class ProtocolManager: @unchecked Sendable {
// MARK: - Sending
func sendPacket(_ packet: any Packet) {
let id = String(type(of: packet).packetId, radix: 16)
if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected {
Self.logger.info("⏳ Queueing packet 0x\(id) — connected=\(self.client.isConnected), handshake=\(self.handshakeComplete)")
enqueuePacket(packet)
return
}
Self.logger.info("📤 Sending packet 0x\(id) directly")
sendPacketDirect(packet)
}
@@ -172,7 +184,7 @@ final class ProtocolManager: @unchecked Sendable {
protocolVersion: 1,
heartbeatInterval: 15,
device: device,
handshakeState: .needDeviceVerification
handshakeState: .completed
)
sendPacketDirect(handshake)
@@ -254,6 +266,14 @@ final class ProtocolManager: @unchecked Sendable {
if let p = packet as? PacketTyping {
onTypingReceived?(p)
}
case 0x17:
if let p = packet as? PacketDeviceList {
handleDeviceList(p)
}
case 0x18:
if let p = packet as? PacketDeviceResolve {
handleDeviceResolve(p)
}
case 0x19:
if let p = packet as? PacketSync {
onSyncReceived?(p)
@@ -292,8 +312,14 @@ final class ProtocolManager: @unchecked Sendable {
case .needDeviceVerification:
handshakeComplete = false
Self.logger.info("Server requires device verification")
clearPacketQueue()
Self.logger.info("Server requires device verification — approve this device from your other Rosetta app")
Task { @MainActor in
self.connectionState = .deviceVerificationRequired
}
// Keep packet queue: messages will be flushed when the other device
// approves this login and the server re-sends handshake with .completed
startHeartbeat(interval: packet.heartbeatInterval)
}
}
@@ -370,6 +396,57 @@ final class ProtocolManager: @unchecked Sendable {
packetQueueLock.unlock()
}
// MARK: - Device Verification
private func handleDeviceList(_ packet: PacketDeviceList) {
Self.logger.info("📱 Device list received: \(packet.devices.count) devices")
for device in packet.devices {
Self.logger.info(" - \(device.deviceName) (\(device.deviceOs)) status=\(device.deviceStatus.rawValue) verify=\(device.deviceVerify.rawValue)")
}
Task { @MainActor in
self.devices = packet.devices
self.pendingDeviceVerification = packet.devices.first { $0.deviceVerify == .notVerified }
}
}
private func handleDeviceResolve(_ packet: PacketDeviceResolve) {
Self.logger.info("🔐 Device resolve received: deviceId=\(packet.deviceId.prefix(20)), solution=\(packet.solution.rawValue)")
if packet.solution == .decline {
Self.logger.info("🚫 This device was DECLINED — disconnecting")
disconnect()
}
// If accepted, server will re-send handshake with .completed
// which is handled by handleHandshakeResponse
}
/// Accept a pending device login from another device.
func acceptDevice(_ deviceId: String) {
Self.logger.info("✅ Accepting device: \(deviceId.prefix(20))")
var packet = PacketDeviceResolve()
packet.deviceId = deviceId
packet.solution = .accept
sendPacketDirect(packet)
Task { @MainActor in
self.pendingDeviceVerification = nil
}
}
/// Decline a pending device login from another device.
func declineDevice(_ deviceId: String) {
Self.logger.info("❌ Declining device: \(deviceId.prefix(20))")
var packet = PacketDeviceResolve()
packet.deviceId = deviceId
packet.solution = .decline
sendPacketDirect(packet)
Task { @MainActor in
self.pendingDeviceVerification = nil
}
}
private func packetQueueKey(_ packet: any Packet) -> String? {
switch packet {
case let message as PacketMessage: