Онлайн-статусы, исправление навигации и 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:
@@ -190,7 +190,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
@@ -262,12 +262,14 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = U6DMAKWNV3;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Rosetta;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
|
||||||
@@ -278,16 +280,17 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = rosetta.app.Rosetta;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -296,12 +299,14 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = U6DMAKWNV3;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Rosetta;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
|
||||||
@@ -312,16 +317,17 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = rosetta.app.Rosetta;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -183,11 +183,16 @@ final class DialogRepository {
|
|||||||
schedulePersist()
|
schedulePersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0) {
|
func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) {
|
||||||
guard var dialog = dialogs[publicKey] else { return }
|
guard var dialog = dialogs[publicKey] else { return }
|
||||||
if !title.isEmpty { dialog.opponentTitle = title }
|
if !title.isEmpty { dialog.opponentTitle = title }
|
||||||
if !username.isEmpty { dialog.opponentUsername = username }
|
if !username.isEmpty { dialog.opponentUsername = username }
|
||||||
if verified > 0 { dialog.verified = max(dialog.verified, verified) }
|
if verified > 0 { dialog.verified = max(dialog.verified, verified) }
|
||||||
|
// online: 0 = offline, 1 = online, -1 = not provided (don't update)
|
||||||
|
if online >= 0 {
|
||||||
|
dialog.isOnline = online > 0
|
||||||
|
if !dialog.isOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) }
|
||||||
|
}
|
||||||
dialogs[publicKey] = dialog
|
dialogs[publicKey] = dialog
|
||||||
schedulePersist()
|
schedulePersist()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ enum PacketRegistry {
|
|||||||
0x01: { PacketUserInfo() },
|
0x01: { PacketUserInfo() },
|
||||||
0x02: { PacketResult() },
|
0x02: { PacketResult() },
|
||||||
0x03: { PacketSearch() },
|
0x03: { PacketSearch() },
|
||||||
|
0x04: { PacketOnlineSubscribe() },
|
||||||
0x05: { PacketOnlineState() },
|
0x05: { PacketOnlineState() },
|
||||||
0x06: { PacketMessage() },
|
0x06: { PacketMessage() },
|
||||||
0x07: { PacketRead() },
|
0x07: { PacketRead() },
|
||||||
0x08: { PacketDelivery() },
|
0x08: { PacketDelivery() },
|
||||||
0x0B: { PacketTyping() },
|
0x0B: { PacketTyping() },
|
||||||
0x17: { PacketDeviceList() },
|
0x17: { PacketDeviceList() },
|
||||||
|
0x18: { PacketDeviceResolve() },
|
||||||
0x19: { PacketSync() },
|
0x19: { PacketSync() },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - DeviceResolveSolution
|
||||||
|
|
||||||
|
enum DeviceResolveSolution: Int {
|
||||||
|
case accept = 0
|
||||||
|
case decline = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PacketDeviceResolve (0x18)
|
||||||
|
|
||||||
|
/// Sent to accept or decline a pending device login request.
|
||||||
|
/// Also received from server when another device approves/declines this device.
|
||||||
|
struct PacketDeviceResolve: Packet {
|
||||||
|
static let packetId = 0x18
|
||||||
|
|
||||||
|
var deviceId: String = ""
|
||||||
|
var solution: DeviceResolveSolution = .decline
|
||||||
|
|
||||||
|
func write(to stream: Stream) {
|
||||||
|
stream.writeString(deviceId)
|
||||||
|
stream.writeInt8(solution.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func read(from stream: Stream) {
|
||||||
|
deviceId = stream.readString()
|
||||||
|
solution = DeviceResolveSolution(rawValue: stream.readInt8()) ?? .decline
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ struct PacketHandshake: Packet {
|
|||||||
var protocolVersion: Int = 1
|
var protocolVersion: Int = 1
|
||||||
var heartbeatInterval: Int = 15
|
var heartbeatInterval: Int = 15
|
||||||
var device = HandshakeDevice()
|
var device = HandshakeDevice()
|
||||||
var handshakeState: HandshakeState = .needDeviceVerification
|
var handshakeState: HandshakeState = .completed
|
||||||
|
|
||||||
func write(to stream: Stream) {
|
func write(to stream: Stream) {
|
||||||
stream.writeString(privateKey)
|
stream.writeString(privateKey)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// OnlineSubscribe packet (0x04) — subscribe to a user's online status updates.
|
||||||
|
/// Client sends this for each dialog opponent to receive PacketOnlineState (0x05) updates.
|
||||||
|
struct PacketOnlineSubscribe: Packet {
|
||||||
|
static let packetId = 0x04
|
||||||
|
|
||||||
|
var publicKey: String = ""
|
||||||
|
|
||||||
|
func write(to stream: Stream) {
|
||||||
|
stream.writeString(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func read(from stream: Stream) {
|
||||||
|
publicKey = stream.readString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ enum ConnectionState: String {
|
|||||||
case connecting
|
case connecting
|
||||||
case connected
|
case connected
|
||||||
case handshaking
|
case handshaking
|
||||||
|
case deviceVerificationRequired
|
||||||
case authenticated
|
case authenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,14 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
|
|
||||||
private(set) var connectionState: ConnectionState = .disconnected
|
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
|
// MARK: - Callbacks
|
||||||
|
|
||||||
var onMessageReceived: ((PacketMessage) -> Void)?
|
var onMessageReceived: ((PacketMessage) -> Void)?
|
||||||
@@ -92,10 +101,13 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
// MARK: - Sending
|
// MARK: - Sending
|
||||||
|
|
||||||
func sendPacket(_ packet: any Packet) {
|
func sendPacket(_ packet: any Packet) {
|
||||||
|
let id = String(type(of: packet).packetId, radix: 16)
|
||||||
if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected {
|
if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected {
|
||||||
|
Self.logger.info("⏳ Queueing packet 0x\(id) — connected=\(self.client.isConnected), handshake=\(self.handshakeComplete)")
|
||||||
enqueuePacket(packet)
|
enqueuePacket(packet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Self.logger.info("📤 Sending packet 0x\(id) directly")
|
||||||
sendPacketDirect(packet)
|
sendPacketDirect(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +184,7 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
heartbeatInterval: 15,
|
heartbeatInterval: 15,
|
||||||
device: device,
|
device: device,
|
||||||
handshakeState: .needDeviceVerification
|
handshakeState: .completed
|
||||||
)
|
)
|
||||||
|
|
||||||
sendPacketDirect(handshake)
|
sendPacketDirect(handshake)
|
||||||
@@ -254,6 +266,14 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
if let p = packet as? PacketTyping {
|
if let p = packet as? PacketTyping {
|
||||||
onTypingReceived?(p)
|
onTypingReceived?(p)
|
||||||
}
|
}
|
||||||
|
case 0x17:
|
||||||
|
if let p = packet as? PacketDeviceList {
|
||||||
|
handleDeviceList(p)
|
||||||
|
}
|
||||||
|
case 0x18:
|
||||||
|
if let p = packet as? PacketDeviceResolve {
|
||||||
|
handleDeviceResolve(p)
|
||||||
|
}
|
||||||
case 0x19:
|
case 0x19:
|
||||||
if let p = packet as? PacketSync {
|
if let p = packet as? PacketSync {
|
||||||
onSyncReceived?(p)
|
onSyncReceived?(p)
|
||||||
@@ -292,8 +312,14 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
|
|
||||||
case .needDeviceVerification:
|
case .needDeviceVerification:
|
||||||
handshakeComplete = false
|
handshakeComplete = false
|
||||||
Self.logger.info("Server requires device verification")
|
Self.logger.info("Server requires device verification — approve this device from your other Rosetta app")
|
||||||
clearPacketQueue()
|
|
||||||
|
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)
|
startHeartbeat(interval: packet.heartbeatInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +396,57 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
packetQueueLock.unlock()
|
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? {
|
private func packetQueueKey(_ packet: any Packet) -> String? {
|
||||||
switch packet {
|
switch packet {
|
||||||
case let message as PacketMessage:
|
case let message as PacketMessage:
|
||||||
|
|||||||
@@ -37,8 +37,11 @@ final class SessionManager {
|
|||||||
private let maxOutgoingRetryAttempts = 3
|
private let maxOutgoingRetryAttempts = 3
|
||||||
private let maxOutgoingWaitingLifetimeMs: Int64 = 80_000
|
private let maxOutgoingWaitingLifetimeMs: Int64 = 80_000
|
||||||
|
|
||||||
|
private var userInfoSearchHandlerToken: UUID?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
setupProtocolCallbacks()
|
setupProtocolCallbacks()
|
||||||
|
setupUserInfoSearchHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Lifecycle
|
// MARK: - Session Lifecycle
|
||||||
@@ -83,9 +86,13 @@ final class SessionManager {
|
|||||||
/// Sends an encrypted message to a recipient, matching Android's outgoing flow.
|
/// Sends an encrypted message to a recipient, matching Android's outgoing flow.
|
||||||
func sendMessage(text: String, toPublicKey: String) async throws {
|
func sendMessage(text: String, toPublicKey: String) async throws {
|
||||||
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
|
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
|
||||||
|
Self.logger.error("📤 Cannot send — missing keys")
|
||||||
throw CryptoError.decryptionFailed
|
throw CryptoError.decryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let connState = ProtocolManager.shared.connectionState
|
||||||
|
Self.logger.info("📤 sendMessage to \(toPublicKey.prefix(12))… conn=\(String(describing: connState))")
|
||||||
|
|
||||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
let packet = try makeOutgoingPacket(
|
let packet = try makeOutgoingPacket(
|
||||||
@@ -97,10 +104,12 @@ final class SessionManager {
|
|||||||
privateKeyHash: hash
|
privateKeyHash: hash
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Use existing dialog title/username instead of overwriting with empty strings
|
||||||
|
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
||||||
DialogRepository.shared.ensureDialog(
|
DialogRepository.shared.ensureDialog(
|
||||||
opponentKey: toPublicKey,
|
opponentKey: toPublicKey,
|
||||||
title: "",
|
title: existingDialog?.opponentTitle ?? "",
|
||||||
username: "",
|
username: existingDialog?.opponentUsername ?? "",
|
||||||
myPublicKey: currentPublicKey
|
myPublicKey: currentPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -302,6 +311,11 @@ final class SessionManager {
|
|||||||
// Android parity: request message synchronization after authentication.
|
// Android parity: request message synchronization after authentication.
|
||||||
self.requestSynchronize()
|
self.requestSynchronize()
|
||||||
self.retryWaitingOutgoingMessagesAfterReconnect()
|
self.retryWaitingOutgoingMessagesAfterReconnect()
|
||||||
|
|
||||||
|
// Clear dedup set so we re-fetch user info (including online status) after reconnect.
|
||||||
|
self.requestedUserInfoKeys.removeAll()
|
||||||
|
// Request fresh online status for all existing dialogs via PacketSearch.
|
||||||
|
self.refreshOnlineStatusForAllDialogs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +489,19 @@ final class SessionManager {
|
|||||||
"rosetta_last_sync_\(currentPublicKey)"
|
"rosetta_last_sync_\(currentPublicKey)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Online Status Subscription
|
||||||
|
|
||||||
|
/// Subscribe to a single user's online status (called when opening a chat, like Android).
|
||||||
|
/// Only sends when authenticated — does NOT queue to avoid flooding server on reconnect.
|
||||||
|
func subscribeToOnlineStatus(publicKey: String) {
|
||||||
|
guard !publicKey.isEmpty,
|
||||||
|
ProtocolManager.shared.connectionState == .authenticated
|
||||||
|
else { return }
|
||||||
|
var packet = PacketOnlineSubscribe()
|
||||||
|
packet.publicKey = publicKey
|
||||||
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
private func requestSynchronize(cursor: Int64? = nil) {
|
private func requestSynchronize(cursor: Int64? = nil) {
|
||||||
guard ProtocolManager.shared.connectionState == .authenticated else { return }
|
guard ProtocolManager.shared.connectionState == .authenticated else { return }
|
||||||
guard !syncRequestInFlight else { return }
|
guard !syncRequestInFlight else { return }
|
||||||
@@ -601,6 +628,11 @@ final class SessionManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public convenience for views that need to trigger a user-info fetch.
|
||||||
|
func requestUserInfoIfNeeded(forKey publicKey: String) {
|
||||||
|
requestUserInfoIfNeeded(opponentKey: publicKey, privateKeyHash: privateKeyHash)
|
||||||
|
}
|
||||||
|
|
||||||
private func requestUserInfoIfNeeded(opponentKey: String, privateKeyHash: String?) {
|
private func requestUserInfoIfNeeded(opponentKey: String, privateKeyHash: String?) {
|
||||||
guard let privateKeyHash else { return }
|
guard let privateKeyHash else { return }
|
||||||
let normalized = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalized = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -615,6 +647,42 @@ final class SessionManager {
|
|||||||
ProtocolManager.shared.sendPacket(searchPacket)
|
ProtocolManager.shared.sendPacket(searchPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After handshake, request user info for all existing dialog opponents.
|
||||||
|
/// This populates online status from search results (PacketSearch response includes `online` field).
|
||||||
|
private func refreshOnlineStatusForAllDialogs() {
|
||||||
|
let dialogs = DialogRepository.shared.dialogs
|
||||||
|
let ownKey = currentPublicKey
|
||||||
|
var count = 0
|
||||||
|
for (key, _) in dialogs {
|
||||||
|
guard key != ownKey, !key.isEmpty else { continue }
|
||||||
|
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
Self.logger.info("Refreshing online status for \(count) dialogs")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent handler for ALL search results — updates dialog names/usernames from server data.
|
||||||
|
/// This runs independently of ChatListViewModel's search UI handler.
|
||||||
|
private func setupUserInfoSearchHandler() {
|
||||||
|
userInfoSearchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard self != nil else { return }
|
||||||
|
for user in packet.users {
|
||||||
|
guard !user.publicKey.isEmpty else { continue }
|
||||||
|
Self.logger.debug("🔍 Search result: \(user.publicKey.prefix(12))… title='\(user.title)' online=\(user.online) verified=\(user.verified)")
|
||||||
|
// Update user info + online status from search results
|
||||||
|
DialogRepository.shared.updateUserInfo(
|
||||||
|
publicKey: user.publicKey,
|
||||||
|
title: user.title,
|
||||||
|
username: user.username,
|
||||||
|
verified: user.verified,
|
||||||
|
online: user.online
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func makeOutgoingPacket(
|
private func makeOutgoingPacket(
|
||||||
text: String,
|
text: String,
|
||||||
toPublicKey: String,
|
toPublicKey: String,
|
||||||
|
|||||||
@@ -38,16 +38,16 @@ struct AvatarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.overlay(alignment: .bottomTrailing) {
|
.overlay(alignment: .bottomLeading) {
|
||||||
if isOnline {
|
if isOnline {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(RosettaColors.online)
|
.fill(Color(hex: 0x4CD964))
|
||||||
.frame(width: badgeSize, height: badgeSize)
|
.frame(width: badgeSize, height: badgeSize)
|
||||||
.overlay {
|
.overlay {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(RosettaColors.Adaptive.background, lineWidth: 2)
|
.stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.05)
|
||||||
}
|
}
|
||||||
.offset(x: 1, y: 1)
|
.offset(x: -1, y: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityLabel(isSavedMessages ? "Saved Messages" : initials)
|
.accessibilityLabel(isSavedMessages ? "Saved Messages" : initials)
|
||||||
|
|||||||
28
Rosetta/DesignSystem/Components/SwipeBackModifier.swift
Normal file
28
Rosetta/DesignSystem/Components/SwipeBackModifier.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Re-enables the iOS interactive swipe-back gesture when
|
||||||
|
/// `.navigationBarBackButtonHidden(true)` is used in SwiftUI.
|
||||||
|
struct SwipeBackGestureEnabler: UIViewControllerRepresentable {
|
||||||
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
|
SwipeBackController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||||
|
|
||||||
|
private final class SwipeBackController: UIViewController {
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
if let nav = navigationController {
|
||||||
|
nav.interactivePopGestureRecognizer?.isEnabled = true
|
||||||
|
nav.interactivePopGestureRecognizer?.delegate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Restores the swipe-back gesture after hiding the default back button.
|
||||||
|
func enableSwipeBack() -> some View {
|
||||||
|
background(SwipeBackGestureEnabler())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,38 +29,25 @@ struct ChatDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var titleText: String {
|
private var titleText: String {
|
||||||
if route.isSavedMessages {
|
if route.isSavedMessages { return "Saved Messages" }
|
||||||
return "Saved Messages"
|
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
|
||||||
}
|
if !route.title.isEmpty { return route.title }
|
||||||
if let dialog, !dialog.opponentTitle.isEmpty {
|
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
|
||||||
return dialog.opponentTitle
|
if !route.username.isEmpty { return "@\(route.username)" }
|
||||||
}
|
|
||||||
if !route.title.isEmpty {
|
|
||||||
return route.title
|
|
||||||
}
|
|
||||||
if let dialog, !dialog.opponentUsername.isEmpty {
|
|
||||||
return "@\(dialog.opponentUsername)"
|
|
||||||
}
|
|
||||||
if !route.username.isEmpty {
|
|
||||||
return "@\(route.username)"
|
|
||||||
}
|
|
||||||
return String(route.publicKey.prefix(12))
|
return String(route.publicKey.prefix(12))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var effectiveVerified: Int {
|
||||||
|
if let dialog { return dialog.effectiveVerified }
|
||||||
|
if route.verified > 0 { return route.verified }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
private var subtitleText: String {
|
private var subtitleText: String {
|
||||||
if isTyping {
|
if route.isSavedMessages { return "" }
|
||||||
return "typing..."
|
if isTyping { return "typing..." }
|
||||||
}
|
if let dialog, dialog.isOnline { return "online" }
|
||||||
if let dialog, dialog.isOnline {
|
return "offline"
|
||||||
return "online"
|
|
||||||
}
|
|
||||||
if let dialog, !dialog.opponentUsername.isEmpty {
|
|
||||||
return "@\(dialog.opponentUsername)"
|
|
||||||
}
|
|
||||||
if !route.username.isEmpty {
|
|
||||||
return "@\(route.username)"
|
|
||||||
}
|
|
||||||
return String(route.publicKey.prefix(12))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var trimmedMessage: String {
|
private var trimmedMessage: String {
|
||||||
@@ -86,7 +73,7 @@ struct ChatDetailView: View {
|
|||||||
private var sendButtonWidth: CGFloat { 38 }
|
private var sendButtonWidth: CGFloat { 38 }
|
||||||
private var sendButtonHeight: CGFloat { 36 }
|
private var sendButtonHeight: CGFloat { 36 }
|
||||||
|
|
||||||
private var composerHorizontalPadding: CGFloat {
|
private var composerTrailingPadding: CGFloat {
|
||||||
isInputFocused ? 16 : 28
|
isInputFocused ? 16 : 28
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,47 +85,121 @@ struct ChatDetailView: View {
|
|||||||
|
|
||||||
private static let scrollBottomAnchorId = "chat_detail_bottom_anchor"
|
private static let scrollBottomAnchorId = "chat_detail_bottom_anchor"
|
||||||
|
|
||||||
var body: some View {
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background.ignoresSafeArea()
|
||||||
|
|
||||||
|
tiledChatBackground
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140))
|
messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140))
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) { composer }
|
||||||
chatHeaderContainer
|
|
||||||
}
|
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
||||||
composer
|
|
||||||
}
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true) // скрываем стандартный back, но НЕ навбар
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.enableSwipeBack()
|
||||||
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
|
.applyGlassNavBar()
|
||||||
|
.toolbar { chatDetailToolbar } // твой header тут
|
||||||
.toolbar(.hidden, for: .tabBar)
|
.toolbar(.hidden, for: .tabBar)
|
||||||
.onAppear {
|
.task {
|
||||||
onPresentedChange?(true)
|
// Request user info (non-mutating, won't trigger list rebuild)
|
||||||
|
requestUserInfoIfNeeded()
|
||||||
|
// Delay ALL dialog mutations to let navigation transition complete.
|
||||||
|
// Without this, DialogRepository update rebuilds ChatListView's ForEach
|
||||||
|
// mid-navigation, recreating the NavigationLink and canceling the push.
|
||||||
|
try? await Task.sleep(for: .milliseconds(600))
|
||||||
activateDialog()
|
activateDialog()
|
||||||
markDialogAsRead()
|
markDialogAsRead()
|
||||||
|
// Subscribe to opponent's online status (Android parity) — only after settled
|
||||||
|
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
onPresentedChange?(false)
|
|
||||||
messageRepository.setDialogActive(route.publicKey, isActive: false)
|
messageRepository.setDialogActive(route.publicKey, isActive: false)
|
||||||
}
|
}
|
||||||
.onChange(of: messageText) { _, newValue in
|
|
||||||
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatDetailView {
|
var body: some View { content }
|
||||||
var avatarInitials: String {
|
|
||||||
if route.isSavedMessages {
|
|
||||||
return "S"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension ChatDetailView {
|
||||||
|
// MARK: - Toolbar (как в ChatListView)
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
var chatDetailToolbar: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button { dismiss() } label: { backCircleButtonLabel }
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||||
|
.accessibilityLabel("Back")
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Text(titleText)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if !route.isSavedMessages && effectiveVerified > 0 {
|
||||||
|
VerifiedBadge(verified: effectiveVerified, size: 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(subtitleText)
|
||||||
|
.font(.system(size: 11, weight: .regular))
|
||||||
|
.foregroundStyle(
|
||||||
|
isTyping || (dialog?.isOnline == true)
|
||||||
|
? RosettaColors.online
|
||||||
|
: RosettaColors.Adaptive.textSecondary
|
||||||
|
)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background {
|
||||||
|
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
AvatarView(
|
||||||
|
initials: avatarInitials,
|
||||||
|
colorIndex: avatarColorIndex,
|
||||||
|
size: 35,
|
||||||
|
isOnline: false,
|
||||||
|
isSavedMessages: route.isSavedMessages
|
||||||
|
)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private var backCircleButtonLabel: some View {
|
||||||
|
ZStack {
|
||||||
|
TelegramVectorIcon(
|
||||||
|
pathData: TelegramIconPath.backChevron,
|
||||||
|
viewBox: CGSize(width: 11, height: 20),
|
||||||
|
color: .white
|
||||||
|
)
|
||||||
|
.frame(width: 11, height: 20)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36) // iOS hit-area
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Existing helpers / UI
|
||||||
|
|
||||||
|
var avatarInitials: String {
|
||||||
|
if route.isSavedMessages { return "S" }
|
||||||
return RosettaColors.initials(name: titleText, publicKey: route.publicKey)
|
return RosettaColors.initials(name: titleText, publicKey: route.publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,82 +211,73 @@ private extension ChatDetailView {
|
|||||||
RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E))
|
RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E))
|
||||||
}
|
}
|
||||||
|
|
||||||
var chatHeader: some View {
|
/// Tiled chat background with properly scaled tiles (200pt wide)
|
||||||
HStack(spacing: 10) {
|
private var tiledChatBackground: some View {
|
||||||
Button {
|
Group {
|
||||||
dismiss()
|
if let uiImage = UIImage(named: "ChatBackground"),
|
||||||
} label: {
|
let cgImage = uiImage.cgImage {
|
||||||
TelegramVectorIcon(
|
let tileWidth: CGFloat = 200
|
||||||
pathData: TelegramIconPath.backChevron,
|
let scaleFactor = uiImage.size.width / tileWidth
|
||||||
viewBox: CGSize(width: 11, height: 20),
|
let scaledImage = UIImage(
|
||||||
color: .white
|
cgImage: cgImage,
|
||||||
|
scale: uiImage.scale * scaleFactor,
|
||||||
|
orientation: .up
|
||||||
)
|
)
|
||||||
.frame(width: 11, height: 20)
|
Color(uiColor: UIColor(patternImage: scaledImage))
|
||||||
.frame(width: 44, height: 44)
|
.opacity(0.18)
|
||||||
.background {
|
} else {
|
||||||
headerCircleBackground(strokeOpacity: 0.22)
|
Color.clear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Back")
|
|
||||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
|
||||||
|
|
||||||
Spacer(minLength: 6)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
Text(titleText)
|
|
||||||
.font(.system(size: 15, weight: .semibold))
|
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(subtitleText)
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundStyle(
|
|
||||||
isTyping || (dialog?.isOnline == true)
|
|
||||||
? RosettaColors.online
|
|
||||||
: RosettaColors.Adaptive.textSecondary
|
|
||||||
)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background {
|
|
||||||
headerCapsuleBackground(strokeOpacity: 0.20)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 6)
|
// MARK: - Messages
|
||||||
|
|
||||||
AvatarView(
|
|
||||||
initials: avatarInitials,
|
|
||||||
colorIndex: avatarColorIndex,
|
|
||||||
size: 38,
|
|
||||||
isOnline: dialog?.isOnline ?? false,
|
|
||||||
isSavedMessages: route.isSavedMessages
|
|
||||||
)
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.background {
|
|
||||||
headerCircleBackground(strokeOpacity: 0.22)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Color.clear)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var chatHeaderContainer: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
chatHeader
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.top, 0)
|
|
||||||
.padding(.bottom, 6)
|
|
||||||
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.white.opacity(0.06))
|
|
||||||
.frame(height: 0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func messagesList(maxBubbleWidth: CGFloat) -> some View {
|
func messagesList(maxBubbleWidth: CGFloat) -> some View {
|
||||||
|
if messages.isEmpty {
|
||||||
|
emptyStateView
|
||||||
|
} else {
|
||||||
|
messagesScrollView(maxBubbleWidth: maxBubbleWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyStateView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
AvatarView(
|
||||||
|
initials: avatarInitials,
|
||||||
|
colorIndex: avatarColorIndex,
|
||||||
|
size: 80,
|
||||||
|
isOnline: dialog?.isOnline ?? false,
|
||||||
|
isSavedMessages: route.isSavedMessages
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(titleText)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
|
||||||
|
if !route.isSavedMessages {
|
||||||
|
Text(subtitleText)
|
||||||
|
.font(.system(size: 14, weight: .regular))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(route.isSavedMessages
|
||||||
|
? "Save messages here for quick access"
|
||||||
|
: "No messages yet")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { isInputFocused = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func messagesScrollView(maxBubbleWidth: CGFloat) -> some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
let scroll = ScrollView(.vertical, showsIndicators: false) {
|
let scroll = ScrollView(.vertical, showsIndicators: false) {
|
||||||
LazyVStack(spacing: 6) {
|
LazyVStack(spacing: 6) {
|
||||||
@@ -238,18 +290,6 @@ private extension ChatDetailView {
|
|||||||
.id(message.id)
|
.id(message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if messages.isEmpty {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "bubble.left.and.bubble.right")
|
|
||||||
.font(.system(size: 34, weight: .regular))
|
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.45))
|
|
||||||
Text("Start messaging")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
||||||
}
|
|
||||||
.padding(.top, 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
.id(Self.scrollBottomAnchorId)
|
.id(Self.scrollBottomAnchorId)
|
||||||
@@ -259,17 +299,14 @@ private extension ChatDetailView {
|
|||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onTapGesture {
|
.onTapGesture { isInputFocused = false }
|
||||||
isInputFocused = false
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
|
||||||
scrollToBottom(proxy: proxy, animated: false)
|
|
||||||
}
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .milliseconds(120))
|
try? await Task.sleep(for: .milliseconds(120))
|
||||||
scrollToBottom(proxy: proxy, animated: false)
|
scrollToBottom(proxy: proxy, animated: false)
|
||||||
}
|
}
|
||||||
|
markDialogAsRead()
|
||||||
}
|
}
|
||||||
.onChange(of: messages.count) { _, _ in
|
.onChange(of: messages.count) { _, _ in
|
||||||
scrollToBottom(proxy: proxy, animated: true)
|
scrollToBottom(proxy: proxy, animated: true)
|
||||||
@@ -293,51 +330,38 @@ private extension ChatDetailView {
|
|||||||
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, isTailVisible: Bool) -> some View {
|
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, isTailVisible: Bool) -> some View {
|
||||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||||
let messageText = message.text.isEmpty ? " " : message.text
|
let messageText = message.text.isEmpty ? " " : message.text
|
||||||
let textMaxWidth = max(maxBubbleWidth - 28, 40)
|
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
// Text determines bubble width; timestamp overlays at bottom-trailing.
|
||||||
if outgoing {
|
// minWidth ensures the bubble is wide enough for the timestamp row.
|
||||||
Spacer(minLength: 56)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
|
||||||
Text(messageText)
|
Text(messageText)
|
||||||
.font(.system(size: 16, weight: .regular))
|
.font(.system(size: 16, weight: .regular))
|
||||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineSpacing(0)
|
.lineSpacing(0)
|
||||||
.frame(maxWidth: textMaxWidth, alignment: .leading)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 22)
|
||||||
|
.frame(minWidth: outgoing ? 90 : 70, alignment: .leading)
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(messageTime(message.timestamp))
|
Text(messageTime(message.timestamp))
|
||||||
.font(.system(size: 12, weight: .regular))
|
.font(.system(size: 12, weight: .regular))
|
||||||
.foregroundStyle(
|
.foregroundStyle(outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary)
|
||||||
outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary
|
|
||||||
)
|
|
||||||
|
|
||||||
if outgoing {
|
if outgoing { deliveryIndicator(message.deliveryStatus) }
|
||||||
deliveryIndicator(message.deliveryStatus)
|
|
||||||
}
|
}
|
||||||
}
|
.padding(.trailing, 14)
|
||||||
.frame(maxWidth: textMaxWidth, alignment: .trailing)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
.background {
|
|
||||||
bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: maxBubbleWidth, alignment: .leading)
|
.background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) }
|
||||||
|
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||||
if !outgoing {
|
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
|
||||||
Spacer(minLength: 56)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Composer
|
||||||
|
|
||||||
var composer: some View {
|
var composer: some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
if let sendError {
|
if let sendError {
|
||||||
@@ -348,7 +372,7 @@ private extension ChatDetailView {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(alignment: .bottom, spacing: shouldShowSendButton ? 0 : 6) {
|
HStack(alignment: .bottom, spacing: 0) {
|
||||||
Button {
|
Button {
|
||||||
// Placeholder for attachment picker
|
// Placeholder for attachment picker
|
||||||
} label: {
|
} label: {
|
||||||
@@ -359,7 +383,7 @@ private extension ChatDetailView {
|
|||||||
)
|
)
|
||||||
.frame(width: 21, height: 24)
|
.frame(width: 21, height: 24)
|
||||||
.frame(width: 42, height: 42)
|
.frame(width: 42, height: 42)
|
||||||
.background { floatingCircleBackground(strokeOpacity: 0.18) }
|
.background { glass(shape: .circle, strokeOpacity: 0.18) }
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Attach")
|
.accessibilityLabel("Attach")
|
||||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||||
@@ -378,9 +402,7 @@ private extension ChatDetailView {
|
|||||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||||
|
|
||||||
HStack(alignment: .center, spacing: 0) {
|
HStack(alignment: .center, spacing: 0) {
|
||||||
Button {
|
Button { } label: {
|
||||||
// Placeholder for quick actions
|
|
||||||
} label: {
|
|
||||||
TelegramVectorIcon(
|
TelegramVectorIcon(
|
||||||
pathData: TelegramIconPath.emojiMoon,
|
pathData: TelegramIconPath.emojiMoon,
|
||||||
viewBox: CGSize(width: 19, height: 19),
|
viewBox: CGSize(width: 19, height: 19),
|
||||||
@@ -397,7 +419,6 @@ private extension ChatDetailView {
|
|||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
Button(action: sendCurrentMessage) {
|
Button(action: sendCurrentMessage) {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Mirrors the layered blend stack from the original SVG icon.
|
|
||||||
TelegramVectorIcon(
|
TelegramVectorIcon(
|
||||||
pathData: TelegramIconPath.sendPlane,
|
pathData: TelegramIconPath.sendPlane,
|
||||||
viewBox: CGSize(width: 22, height: 19),
|
viewBox: CGSize(width: 22, height: 19),
|
||||||
@@ -445,9 +466,7 @@ private extension ChatDetailView {
|
|||||||
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
|
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
|
||||||
.frame(width: 22, height: 19)
|
.frame(width: 22, height: 19)
|
||||||
.frame(width: sendButtonWidth, height: sendButtonHeight)
|
.frame(width: sendButtonWidth, height: sendButtonHeight)
|
||||||
.background {
|
.background { Capsule().fill(Color(hex: 0x008BFF)) }
|
||||||
Capsule().fill(Color(hex: 0x008BFF))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Send")
|
.accessibilityLabel("Send")
|
||||||
.disabled(!canSend)
|
.disabled(!canSend)
|
||||||
@@ -468,11 +487,8 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
.padding(3)
|
.padding(3)
|
||||||
.frame(minHeight: 42, alignment: .bottom)
|
.frame(minHeight: 42, alignment: .bottom)
|
||||||
.background {
|
.background { glass(shape: .rounded(21), strokeOpacity: 0.18) }
|
||||||
floatingComposerInputBackground(
|
.padding(.leading, 6)
|
||||||
strokeOpacity: 0.18
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: trailingAction) {
|
Button(action: trailingAction) {
|
||||||
TelegramVectorIcon(
|
TelegramVectorIcon(
|
||||||
@@ -482,7 +498,7 @@ private extension ChatDetailView {
|
|||||||
)
|
)
|
||||||
.frame(width: 18, height: 24)
|
.frame(width: 18, height: 24)
|
||||||
.frame(width: 42, height: 42)
|
.frame(width: 42, height: 42)
|
||||||
.background { floatingCircleBackground(strokeOpacity: 0.18) }
|
.background { glass(shape: .circle, strokeOpacity: 0.18) }
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Voice message")
|
.accessibilityLabel("Voice message")
|
||||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||||
@@ -494,10 +510,12 @@ private extension ChatDetailView {
|
|||||||
anchor: .trailing
|
anchor: .trailing
|
||||||
)
|
)
|
||||||
.blur(radius: (1 - micButtonProgress) * 2.4)
|
.blur(radius: (1 - micButtonProgress) * 2.4)
|
||||||
.frame(width: 42 * micButtonProgress, height: 42, alignment: .trailing)
|
.padding(.leading, 6 * micButtonProgress)
|
||||||
|
.frame(width: (42 + 6) * micButtonProgress, height: 42, alignment: .trailing)
|
||||||
.clipped()
|
.clipped()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, composerHorizontalPadding)
|
.padding(.leading, 16)
|
||||||
|
.padding(.trailing, composerTrailingPadding)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
.padding(.bottom, isInputFocused ? 8 : 0)
|
.padding(.bottom, isInputFocused ? 8 : 0)
|
||||||
.animation(composerAnimation, value: canSend)
|
.animation(composerAnimation, value: canSend)
|
||||||
@@ -507,6 +525,8 @@ private extension ChatDetailView {
|
|||||||
.background(Color.clear)
|
.background(Color.clear)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Bubbles / Glass
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View {
|
func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View {
|
||||||
let nearRadius: CGFloat = isTailVisible ? 6 : 17
|
let nearRadius: CGFloat = isTailVisible ? 6 : 17
|
||||||
@@ -529,102 +549,62 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
enum ChatGlassShape {
|
||||||
func floatingCapsuleBackground(strokeOpacity: Double) -> some View {
|
case capsule
|
||||||
if #available(iOS 26.0, *) {
|
case circle
|
||||||
Color.clear
|
case rounded(CGFloat)
|
||||||
.glassEffect(.regular, in: .capsule)
|
|
||||||
} else {
|
|
||||||
Capsule()
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.overlay(
|
|
||||||
Capsule()
|
|
||||||
.stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func floatingComposerInputBackground(strokeOpacity: Double) -> some View {
|
func glass(
|
||||||
|
shape: ChatGlassShape,
|
||||||
|
strokeOpacity: Double = 0.18,
|
||||||
|
strokeColor: Color = RosettaColors.Adaptive.border
|
||||||
|
) -> some View {
|
||||||
if #available(iOS 26.0, *) {
|
if #available(iOS 26.0, *) {
|
||||||
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
|
switch shape {
|
||||||
shape
|
case .capsule:
|
||||||
|
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||||
|
case .circle:
|
||||||
|
Circle().fill(.clear).glassEffect(.regular, in: .circle)
|
||||||
|
case let .rounded(radius):
|
||||||
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
.fill(.clear)
|
.fill(.clear)
|
||||||
.glassEffect(.regular, in: .rect(cornerRadius: 21))
|
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||||
} else {
|
|
||||||
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
|
|
||||||
shape
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.overlay(
|
|
||||||
shape
|
|
||||||
.stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func headerCapsuleBackground(strokeOpacity: Double) -> some View {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
Color.clear
|
|
||||||
.glassEffect(.regular, in: .capsule)
|
|
||||||
} else {
|
} else {
|
||||||
|
let border = strokeColor.opacity(max(0.28, strokeOpacity))
|
||||||
|
switch shape {
|
||||||
|
case .capsule:
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(.ultraThinMaterial)
|
.fill(.ultraThinMaterial)
|
||||||
.overlay(
|
.overlay(Capsule().stroke(border, lineWidth: 0.8))
|
||||||
Capsule()
|
case .circle:
|
||||||
.stroke(Color.white.opacity(strokeOpacity), lineWidth: 0.8)
|
Circle()
|
||||||
)
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay(Circle().stroke(border, lineWidth: 0.8))
|
||||||
|
case let .rounded(radius):
|
||||||
|
let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
|
rounded
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay(rounded.stroke(border, lineWidth: 0.8))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Actions / utils
|
||||||
func headerCircleBackground(strokeOpacity: Double) -> some View {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
Color.clear
|
|
||||||
.glassEffect(.regular, in: .circle)
|
|
||||||
} else {
|
|
||||||
Circle()
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.overlay(
|
|
||||||
Circle()
|
|
||||||
.stroke(Color.white.opacity(strokeOpacity), lineWidth: 0.8)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func floatingCircleBackground(strokeOpacity: Double) -> some View {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
Color.clear
|
|
||||||
.glassEffect(.regular, in: .circle)
|
|
||||||
} else {
|
|
||||||
Circle()
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.overlay(
|
|
||||||
Circle()
|
|
||||||
.stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func trailingAction() {
|
func trailingAction() {
|
||||||
if canSend {
|
if canSend { sendCurrentMessage() }
|
||||||
sendCurrentMessage()
|
else { isInputFocused = true }
|
||||||
} else {
|
|
||||||
isInputFocused = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deliveryTint(_ status: DeliveryStatus) -> Color {
|
func deliveryTint(_ status: DeliveryStatus) -> Color {
|
||||||
switch status {
|
switch status {
|
||||||
case .read:
|
case .read: return Color(hex: 0xA4E2FF)
|
||||||
return Color(hex: 0xA4E2FF)
|
case .delivered: return Color.white.opacity(0.94)
|
||||||
case .delivered:
|
case .error: return RosettaColors.error
|
||||||
return Color.white.opacity(0.94)
|
default: return Color.white.opacity(0.78)
|
||||||
case .error:
|
|
||||||
return RosettaColors.error
|
|
||||||
default:
|
|
||||||
return Color.white.opacity(0.78)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,8 +622,7 @@ private extension ChatDetailView {
|
|||||||
switch status {
|
switch status {
|
||||||
case .read:
|
case .read:
|
||||||
ZStack {
|
ZStack {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark").offset(x: 3)
|
||||||
.offset(x: 3)
|
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
}
|
}
|
||||||
.font(.system(size: 10.5, weight: .semibold))
|
.font(.system(size: 10.5, weight: .semibold))
|
||||||
@@ -677,14 +656,21 @@ private extension ChatDetailView {
|
|||||||
let next = messages[index + 1]
|
let next = messages[index + 1]
|
||||||
let sameSender = current.isFromMe(myPublicKey: currentPublicKey) == next.isFromMe(myPublicKey: currentPublicKey)
|
let sameSender = current.isFromMe(myPublicKey: currentPublicKey) == next.isFromMe(myPublicKey: currentPublicKey)
|
||||||
|
|
||||||
// Group only plain text bubbles from the same side.
|
|
||||||
let currentIsPlainText = current.attachments.isEmpty
|
let currentIsPlainText = current.attachments.isEmpty
|
||||||
let nextIsPlainText = next.attachments.isEmpty
|
let nextIsPlainText = next.attachments.isEmpty
|
||||||
|
|
||||||
return !(sameSender && currentIsPlainText && nextIsPlainText)
|
return !(sameSender && currentIsPlainText && nextIsPlainText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestUserInfoIfNeeded() {
|
||||||
|
// Always request — we need fresh online status even if title is already populated.
|
||||||
|
SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
func activateDialog() {
|
func activateDialog() {
|
||||||
|
// Only update existing dialogs; don't create ghost entries from search.
|
||||||
|
// New dialogs are created when messages are sent/received (SessionManager).
|
||||||
|
if dialogRepository.dialogs[route.publicKey] != nil {
|
||||||
DialogRepository.shared.ensureDialog(
|
DialogRepository.shared.ensureDialog(
|
||||||
opponentKey: route.publicKey,
|
opponentKey: route.publicKey,
|
||||||
title: route.title,
|
title: route.title,
|
||||||
@@ -692,6 +678,7 @@ private extension ChatDetailView {
|
|||||||
verified: route.verified,
|
verified: route.verified,
|
||||||
myPublicKey: currentPublicKey
|
myPublicKey: currentPublicKey
|
||||||
)
|
)
|
||||||
|
}
|
||||||
messageRepository.setDialogActive(route.publicKey, isActive: true)
|
messageRepository.setDialogActive(route.publicKey, isActive: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,6 +714,8 @@ private extension ChatDetailView {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Button Styles
|
||||||
|
|
||||||
private struct ChatDetailGlassPressButtonStyle: ButtonStyle {
|
private struct ChatDetailGlassPressButtonStyle: ButtonStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
@@ -744,6 +733,33 @@ private struct ChatDetailGlassPressButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ChatDetailGlassCirclePressStyle: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
// почти незаметное сжатие
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.988 : 1.0)
|
||||||
|
// очень лёгкое “подсвечивание”
|
||||||
|
.brightness(configuration.isPressed ? 0.025 : 0.0)
|
||||||
|
.overlay {
|
||||||
|
if configuration.isPressed {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.white.opacity(0.10))
|
||||||
|
.blendMode(.overlay)
|
||||||
|
.padding(2)
|
||||||
|
|
||||||
|
// тонкий “inner highlight”
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.18), lineWidth: 0.9)
|
||||||
|
.padding(2)
|
||||||
|
.blendMode(.overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.spring(response: 0.20, dampingFraction: 0.85), value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SVG
|
||||||
|
|
||||||
private struct TelegramVectorIcon: View {
|
private struct TelegramVectorIcon: View {
|
||||||
let pathData: String
|
let pathData: String
|
||||||
let viewBox: CGSize
|
let viewBox: CGSize
|
||||||
@@ -760,15 +776,10 @@ private struct SVGPathShape: Shape {
|
|||||||
let viewBox: CGSize
|
let viewBox: CGSize
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
func path(in rect: CGRect) -> Path {
|
||||||
guard viewBox.width > 0, viewBox.height > 0 else {
|
guard viewBox.width > 0, viewBox.height > 0 else { return Path() }
|
||||||
return Path()
|
|
||||||
}
|
|
||||||
|
|
||||||
var parser = SVGPathParser(pathData: pathData)
|
var parser = SVGPathParser(pathData: pathData)
|
||||||
var output = Path(parser.parse())
|
var output = Path(parser.parse())
|
||||||
output = output.applying(
|
output = output.applying(.init(scaleX: rect.width / viewBox.width, y: rect.height / viewBox.height))
|
||||||
.init(scaleX: rect.width / viewBox.width, y: rect.height / viewBox.height)
|
|
||||||
)
|
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -787,42 +798,22 @@ private struct SVGPathTokenizer {
|
|||||||
while index < chars.count {
|
while index < chars.count {
|
||||||
let ch = chars[index]
|
let ch = chars[index]
|
||||||
|
|
||||||
if ch.isWhitespace || ch == "," {
|
if ch.isWhitespace || ch == "," { index += 1; continue }
|
||||||
index += 1
|
if ch.isLetter { tokens.append(.command(ch)); index += 1; continue }
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ch.isLetter {
|
|
||||||
tokens.append(.command(ch))
|
|
||||||
index += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ch.isNumber || ch == "-" || ch == "+" || ch == "." {
|
if ch.isNumber || ch == "-" || ch == "+" || ch == "." {
|
||||||
let start = index
|
let start = index
|
||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
while index < chars.count {
|
while index < chars.count {
|
||||||
let c = chars[index]
|
let c = chars[index]
|
||||||
let prev = chars[index - 1]
|
let prev = chars[index - 1]
|
||||||
|
if c.isNumber || c == "." || c == "e" || c == "E" { index += 1; continue }
|
||||||
if c.isNumber || c == "." || c == "e" || c == "E" {
|
if (c == "-" || c == "+"), (prev == "e" || prev == "E") { index += 1; continue }
|
||||||
index += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == "-" || c == "+"), (prev == "e" || prev == "E") {
|
|
||||||
index += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
let fragment = String(chars[start..<index])
|
let fragment = String(chars[start..<index])
|
||||||
if let value = Double(fragment) {
|
if let value = Double(fragment) { tokens.append(.number(CGFloat(value))) }
|
||||||
tokens.append(.number(CGFloat(value)))
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,16 +840,11 @@ private struct SVGPathParser {
|
|||||||
while index < tokens.count {
|
while index < tokens.count {
|
||||||
let command = readCommandOrReuse()
|
let command = readCommandOrReuse()
|
||||||
switch command {
|
switch command {
|
||||||
case "M", "m":
|
case "M", "m": parseMove(command)
|
||||||
parseMove(command)
|
case "L", "l": parseLine(command)
|
||||||
case "L", "l":
|
case "H", "h": parseHorizontal(command)
|
||||||
parseLine(command)
|
case "V", "v": parseVertical(command)
|
||||||
case "H", "h":
|
case "C", "c": parseCubic(command)
|
||||||
parseHorizontal(command)
|
|
||||||
case "V", "v":
|
|
||||||
parseVertical(command)
|
|
||||||
case "C", "c":
|
|
||||||
parseCubic(command)
|
|
||||||
case "Z", "z":
|
case "Z", "z":
|
||||||
cgPath.closeSubpath()
|
cgPath.closeSubpath()
|
||||||
current = subpathStart
|
current = subpathStart
|
||||||
@@ -871,9 +857,7 @@ private struct SVGPathParser {
|
|||||||
|
|
||||||
private var isAtCommand: Bool {
|
private var isAtCommand: Bool {
|
||||||
guard index < tokens.count else { return false }
|
guard index < tokens.count else { return false }
|
||||||
if case .command = tokens[index] {
|
if case .command = tokens[index] { return true }
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,10 +881,7 @@ private struct SVGPathParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedPoint(x: CGFloat, y: CGFloat, relative: Bool) -> CGPoint {
|
private func resolvedPoint(x: CGFloat, y: CGFloat, relative: Bool) -> CGPoint {
|
||||||
if relative {
|
relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y)
|
||||||
return CGPoint(x: current.x + x, y: current.y + y)
|
|
||||||
}
|
|
||||||
return CGPoint(x: x, y: y)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mutating func readPoint(relative: Bool) -> CGPoint? {
|
private mutating func readPoint(relative: Bool) -> CGPoint? {
|
||||||
@@ -934,10 +915,7 @@ private struct SVGPathParser {
|
|||||||
private mutating func parseHorizontal(_ command: Character) {
|
private mutating func parseHorizontal(_ command: Character) {
|
||||||
let relative = command.isLowercase
|
let relative = command.isLowercase
|
||||||
while !isAtCommand, let value = readNumber() {
|
while !isAtCommand, let value = readNumber() {
|
||||||
current = CGPoint(
|
current = CGPoint(x: relative ? current.x + value : value, y: current.y)
|
||||||
x: relative ? current.x + value : value,
|
|
||||||
y: current.y
|
|
||||||
)
|
|
||||||
cgPath.addLine(to: current)
|
cgPath.addLine(to: current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,10 +923,7 @@ private struct SVGPathParser {
|
|||||||
private mutating func parseVertical(_ command: Character) {
|
private mutating func parseVertical(_ command: Character) {
|
||||||
let relative = command.isLowercase
|
let relative = command.isLowercase
|
||||||
while !isAtCommand, let value = readNumber() {
|
while !isAtCommand, let value = readNumber() {
|
||||||
current = CGPoint(
|
current = CGPoint(x: current.x, y: relative ? current.y + value : value)
|
||||||
x: current.x,
|
|
||||||
y: relative ? current.y + value : value
|
|
||||||
)
|
|
||||||
cgPath.addLine(to: current)
|
cgPath.addLine(to: current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -975,14 +950,13 @@ private struct SVGPathParser {
|
|||||||
|
|
||||||
private mutating func skipToNextCommand() {
|
private mutating func skipToNextCommand() {
|
||||||
while index < tokens.count {
|
while index < tokens.count {
|
||||||
if case .command = tokens[index] {
|
if case .command = tokens[index] { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
index += 1
|
index += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private enum TelegramIconPath {
|
private enum TelegramIconPath {
|
||||||
static let backChevron = #"M0.317383 10.5957C0.203451 10.498 0.12207 10.376 0.0732422 10.2295C0.0244141 10.0993 0 9.96094 0 9.81445C0 9.66797 0.0244141 9.52962 0.0732422 9.39941C0.12207 9.25293 0.203451 9.13086 0.317383 9.0332L8.83789 0.317383C8.93555 0.219727 9.05762 0.138346 9.2041 0.0732422C9.33431 0.0244141 9.47266 0 9.61914 0C9.74935 0 9.87956 0.0244141 10.0098 0.0732422C10.1562 0.138346 10.2783 0.219727 10.376 0.317383C10.4899 0.431315 10.5713 0.553385 10.6201 0.683594C10.6689 0.830078 10.6934 0.976562 10.6934 1.12305C10.6934 1.25326 10.6689 1.3916 10.6201 1.53809C10.5713 1.66829 10.4899 1.79036 10.376 1.9043L2.63672 9.81445L10.376 17.7246C10.4899 17.8385 10.5713 17.9606 10.6201 18.0908C10.6689 18.2373 10.6934 18.3757 10.6934 18.5059C10.6934 18.6523 10.6689 18.7988 10.6201 18.9453C10.5713 19.0755 10.4899 19.1976 10.376 19.3115C10.2783 19.4092 10.1562 19.4906 10.0098 19.5557C9.87956 19.6045 9.74935 19.6289 9.61914 19.6289C9.47266 19.6289 9.33431 19.6045 9.2041 19.5557C9.05762 19.4906 8.93555 19.4092 8.83789 19.3115L0.317383 10.5957Z"#
|
static let backChevron = #"M0.317383 10.5957C0.203451 10.498 0.12207 10.376 0.0732422 10.2295C0.0244141 10.0993 0 9.96094 0 9.81445C0 9.66797 0.0244141 9.52962 0.0732422 9.39941C0.12207 9.25293 0.203451 9.13086 0.317383 9.0332L8.83789 0.317383C8.93555 0.219727 9.05762 0.138346 9.2041 0.0732422C9.33431 0.0244141 9.47266 0 9.61914 0C9.74935 0 9.87956 0.0244141 10.0098 0.0732422C10.1562 0.138346 10.2783 0.219727 10.376 0.317383C10.4899 0.431315 10.5713 0.553385 10.6201 0.683594C10.6689 0.830078 10.6934 0.976562 10.6934 1.12305C10.6934 1.25326 10.6689 1.3916 10.6201 1.53809C10.5713 1.66829 10.4899 1.79036 10.376 1.9043L2.63672 9.81445L10.376 17.7246C10.4899 17.8385 10.5713 17.9606 10.6201 18.0908C10.6689 18.2373 10.6934 18.3757 10.6934 18.5059C10.6934 18.6523 10.6689 18.7988 10.6201 18.9453C10.5713 19.0755 10.4899 19.1976 10.376 19.3115C10.2783 19.4092 10.1562 19.4906 10.0098 19.5557C9.87956 19.6045 9.74935 19.6289 9.61914 19.6289C9.47266 19.6289 9.33431 19.6045 9.2041 19.5557C9.05762 19.4906 8.93555 19.4092 8.83789 19.3115L0.317383 10.5957Z"#
|
||||||
|
|
||||||
@@ -997,6 +971,9 @@ private enum TelegramIconPath {
|
|||||||
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
ChatDetailView(
|
ChatDetailView(
|
||||||
route: ChatRoute(
|
route: ChatRoute(
|
||||||
publicKey: "demo_public_key",
|
publicKey: "demo_public_key",
|
||||||
@@ -1006,3 +983,6 @@ private enum TelegramIconPath {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,12 +67,35 @@ struct ChatListView: View {
|
|||||||
private extension ChatListView {
|
private extension ChatListView {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var normalContent: some View {
|
var normalContent: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
deviceVerificationBanners
|
||||||
|
|
||||||
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
||||||
ChatEmptyStateView(searchText: "")
|
ChatEmptyStateView(searchText: "")
|
||||||
} else {
|
} else {
|
||||||
dialogList
|
dialogList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var deviceVerificationBanners: some View {
|
||||||
|
let protocol_ = ProtocolManager.shared
|
||||||
|
|
||||||
|
// Banner 1: THIS device needs approval from another device
|
||||||
|
if protocol_.connectionState == .deviceVerificationRequired {
|
||||||
|
DeviceWaitingApprovalBanner()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banner 2: ANOTHER device needs approval from THIS device
|
||||||
|
if let pendingDevice = protocol_.pendingDeviceVerification {
|
||||||
|
DeviceApprovalBanner(
|
||||||
|
device: pendingDevice,
|
||||||
|
onAccept: { protocol_.acceptDevice(pendingDevice.deviceId) },
|
||||||
|
onDecline: { protocol_.declineDevice(pendingDevice.deviceId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var dialogList: some View {
|
var dialogList: some View {
|
||||||
List {
|
List {
|
||||||
@@ -106,7 +129,9 @@ private extension ChatListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func chatRow(_ dialog: Dialog) -> some View {
|
func chatRow(_ dialog: Dialog) -> some View {
|
||||||
NavigationLink(value: ChatRoute(dialog: dialog)) {
|
Button {
|
||||||
|
navigationPath.append(ChatRoute(dialog: dialog))
|
||||||
|
} label: {
|
||||||
ChatRowView(dialog: dialog)
|
ChatRowView(dialog: dialog)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -199,4 +224,83 @@ private extension ChatListView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device Waiting Approval Banner
|
||||||
|
|
||||||
|
/// Shown when THIS device needs approval from another Rosetta device.
|
||||||
|
private struct DeviceWaitingApprovalBanner: View {
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "lock.shield")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(RosettaColors.warning)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Waiting for device approval")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
|
||||||
|
Text("Open Rosetta on your other device and approve this login.")
|
||||||
|
.font(.system(size: 12, weight: .regular))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(RosettaColors.warning.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device Approval Banner
|
||||||
|
|
||||||
|
/// Shown on primary device when another device is requesting access.
|
||||||
|
private struct DeviceApprovalBanner: View {
|
||||||
|
let device: DeviceEntry
|
||||||
|
let onAccept: () -> Void
|
||||||
|
let onDecline: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.shield")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(RosettaColors.error)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("New device login detected")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
|
|
||||||
|
Text("\(device.deviceName) (\(device.deviceOs))")
|
||||||
|
.font(.system(size: 12, weight: .regular))
|
||||||
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onAccept) {
|
||||||
|
Text("Yes, it's me")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(RosettaColors.primaryBlue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onDecline) {
|
||||||
|
Text("No, it's not me!")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(RosettaColors.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.leading, 34)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(RosettaColors.error.opacity(0.08))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview { ChatListView(isSearchActive: .constant(false), onChatDetailVisibilityChange: nil) }
|
#Preview { ChatListView(isSearchActive: .constant(false), onChatDetailVisibilityChange: nil) }
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
@Published var recentSearches: [RecentSearch] = []
|
@Published var recentSearches: [RecentSearch] = []
|
||||||
|
|
||||||
private var searchTask: Task<Void, Never>?
|
private var searchTask: Task<Void, Never>?
|
||||||
|
private var searchRetryTask: Task<Void, Never>?
|
||||||
private var lastSearchedText = ""
|
private var lastSearchedText = ""
|
||||||
private var searchHandlerToken: UUID?
|
private var searchHandlerToken: UUID?
|
||||||
private var recentSearchesCancellable: AnyCancellable?
|
private var recentSearchesCancellable: AnyCancellable?
|
||||||
@@ -107,7 +108,8 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
publicKey: user.publicKey,
|
publicKey: user.publicKey,
|
||||||
title: user.title,
|
title: user.title,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
verified: user.verified
|
verified: user.verified,
|
||||||
|
online: user.online
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +143,10 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
|
|
||||||
guard connState == .authenticated, let hash else {
|
guard connState == .authenticated, let hash else {
|
||||||
self.isServerSearching = false
|
self.isServerSearching = false
|
||||||
|
// Reset so next attempt re-sends instead of being de-duped
|
||||||
|
self.lastSearchedText = ""
|
||||||
|
// Retry after 2 seconds if still have a query
|
||||||
|
self.scheduleSearchRetry()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +159,17 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scheduleSearchRetry() {
|
||||||
|
searchRetryTask?.cancel()
|
||||||
|
searchRetryTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
let q = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !q.isEmpty else { return }
|
||||||
|
self.triggerServerSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func normalizeSearchInput(_ input: String) -> String {
|
private func normalizeSearchInput(_ input: String) -> String {
|
||||||
input.replacingOccurrences(of: "@", with: "")
|
input.replacingOccurrences(of: "@", with: "")
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -2,17 +2,31 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - ChatRowView
|
// MARK: - ChatRowView
|
||||||
|
|
||||||
/// Chat row matching Figma "Row - Chats" component spec:
|
/// Chat row matching Figma "Row - Chats" component spec (node 3994:38947):
|
||||||
/// Row: height 78, paddingLeft 10, paddingRight 16, vertical center
|
///
|
||||||
/// Avatar: 62px circle, 10pt trailing padding
|
/// Row: height 78, pl-10, pr-16, items-center
|
||||||
/// Title: SF Pro Medium 17pt, tracking -0.43, primary color
|
/// Avatar: 62px circle, pr-10
|
||||||
/// Message: SF Pro Regular 15pt, tracking -0.23, secondary color
|
/// Contents: flex-col, h-full, items-start, justify-center, pb-px
|
||||||
/// Time: SF Pro Regular 14pt, tracking -0.23, secondary color
|
/// Title and Trailing Accessories: flex-1, gap-6, items-center, w-full
|
||||||
/// Badges gap: 6pt — verified 12px, muted 12px
|
/// Title and Detail: flex-1, h-63, items-start, overflow-clip
|
||||||
/// Trailing: pt 8, pb 14 — readStatus + time (gap 2), pin/count at bottom
|
/// Title: gap-4, items-center — SF Pro Medium 17/22, tracking -0.43
|
||||||
|
/// Message: h-41 — SF Pro Regular 15/20, tracking -0.23, secondary
|
||||||
|
/// Accessories: h-full, items-center, justify-end
|
||||||
|
/// Contents-Trailing: flex-col, h-full, items-end, justify-between, pt-8
|
||||||
|
/// Time: SF Pro Regular 14/20, tracking -0.23, secondary
|
||||||
|
/// Other: flex-1, items-end, justify-end, pb-14
|
||||||
|
/// Badge: bg-#008BFF, min-w-20, max-w-37, px-4, rounded-full
|
||||||
|
/// SF Pro Regular 15/20, black, tracking -0.23
|
||||||
struct ChatRowView: View {
|
struct ChatRowView: View {
|
||||||
let dialog: Dialog
|
let dialog: Dialog
|
||||||
|
|
||||||
|
var displayTitle: String {
|
||||||
|
if dialog.isSavedMessages { return "Saved Messages" }
|
||||||
|
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
|
||||||
|
if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
|
||||||
|
return String(dialog.opponentKey.prefix(12))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
avatarSection
|
avatarSection
|
||||||
@@ -41,32 +55,38 @@ private extension ChatRowView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Content Section (two-column: title+detail | trailing accessories)
|
// MARK: - Content Section
|
||||||
|
// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px
|
||||||
|
// └─ "Title and Trailing Accessories": flex-1, gap-6, items-center
|
||||||
|
|
||||||
private extension ChatRowView {
|
private extension ChatRowView {
|
||||||
var contentSection: some View {
|
var contentSection: some View {
|
||||||
HStack(alignment: .center, spacing: 6) {
|
HStack(alignment: .center, spacing: 6) {
|
||||||
// Left column: title + message
|
// "Title and Detail": flex-1, h-63, items-start, overflow-clip
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
titleRow
|
titleRow
|
||||||
messageRow
|
messageRow
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.frame(height: 63)
|
||||||
.clipped()
|
.clipped()
|
||||||
|
|
||||||
// Right column: time + pin/badge
|
// "Accessories and Grabber": h-full, items-center, justify-end
|
||||||
trailingColumn
|
trailingColumn
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.frame(height: 63)
|
.frame(maxHeight: .infinity)
|
||||||
|
.padding(.bottom, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Title Row (name + badges)
|
// MARK: - Title Row (name + badges)
|
||||||
|
// Figma "Title": gap-4, items-center, w-full
|
||||||
|
|
||||||
private extension ChatRowView {
|
private extension ChatRowView {
|
||||||
var titleRow: some View {
|
var titleRow: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
|
Text(displayTitle)
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 17, weight: .medium))
|
||||||
.tracking(-0.43)
|
.tracking(-0.43)
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
@@ -89,6 +109,7 @@ private extension ChatRowView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Row
|
// MARK: - Message Row
|
||||||
|
// Figma "Message": h-41, SF Pro Regular 15/20, tracking -0.23, secondary
|
||||||
|
|
||||||
private extension ChatRowView {
|
private extension ChatRowView {
|
||||||
var messageRow: some View {
|
var messageRow: some View {
|
||||||
@@ -96,7 +117,8 @@ private extension ChatRowView {
|
|||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.tracking(-0.23)
|
.tracking(-0.23)
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(2)
|
||||||
|
.frame(height: 41, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
|
||||||
var messageText: String {
|
var messageText: String {
|
||||||
@@ -107,7 +129,10 @@ private extension ChatRowView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Trailing Column (time + delivery on top, pin/badge on bottom)
|
// MARK: - Trailing Column
|
||||||
|
// Figma "Contents - Trailing": flex-col, h-full, items-end, justify-between, pt-8
|
||||||
|
// ├─ "Read Status and Time": gap-2, items-center
|
||||||
|
// └─ "Other": flex-1, items-end, justify-end, pb-14
|
||||||
|
|
||||||
private extension ChatRowView {
|
private extension ChatRowView {
|
||||||
var trailingColumn: some View {
|
var trailingColumn: some View {
|
||||||
@@ -127,7 +152,7 @@ private extension ChatRowView {
|
|||||||
: RosettaColors.Adaptive.textSecondary
|
: RosettaColors.Adaptive.textSecondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.top, 2)
|
.padding(.top, 8)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
@@ -144,7 +169,7 @@ private extension ChatRowView {
|
|||||||
unreadBadge
|
unreadBadge
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 2)
|
.padding(.bottom, 14)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,14 +206,18 @@ private extension ChatRowView {
|
|||||||
let count = dialog.unreadCount
|
let count = dialog.unreadCount
|
||||||
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
|
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
|
||||||
let isMuted = dialog.isMuted
|
let isMuted = dialog.isMuted
|
||||||
|
let isSmall = count < 10
|
||||||
|
|
||||||
return Text(text)
|
return Text(text)
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.tracking(-0.23)
|
.tracking(-0.23)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.black)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, isSmall ? 0 : 4)
|
||||||
.frame(minWidth: 20, minHeight: 20)
|
.frame(
|
||||||
.frame(maxWidth: 37)
|
minWidth: 20,
|
||||||
|
maxWidth: isSmall ? 20 : 37,
|
||||||
|
minHeight: 20
|
||||||
|
)
|
||||||
.background {
|
.background {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
|
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ final class SearchViewModel {
|
|||||||
publicKey: user.publicKey,
|
publicKey: user.publicKey,
|
||||||
title: user.title,
|
title: user.title,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
verified: user.verified
|
verified: user.verified,
|
||||||
|
online: user.online
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ final class SettingsViewModel {
|
|||||||
case .connecting: return "Connecting..."
|
case .connecting: return "Connecting..."
|
||||||
case .connected: return "Connected"
|
case .connected: return "Connected"
|
||||||
case .handshaking: return "Authenticating..."
|
case .handshaking: return "Authenticating..."
|
||||||
|
case .deviceVerificationRequired: return "Device Verification Required"
|
||||||
case .authenticated: return "Online"
|
case .authenticated: return "Online"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user