Онлайн-статусы, исправление навигации и 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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)
}
}
} }
} }
var body: some View { content }
} }
private extension ChatDetailView { private extension ChatDetailView {
var avatarInitials: String { // MARK: - Toolbar (как в ChatListView)
if route.isSavedMessages {
return "S" @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)
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 // MARK: - Messages
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) {
@@ -235,19 +287,7 @@ private extension ChatDetailView {
maxBubbleWidth: maxBubbleWidth, maxBubbleWidth: maxBubbleWidth,
isTailVisible: isTailVisible(for: index) isTailVisible: isTailVisible(for: index)
) )
.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
@@ -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) {
if outgoing {
Spacer(minLength: 56)
}
VStack(alignment: .leading, spacing: 3) {
Text(messageText)
.font(.system(size: 16, weight: .regular))
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.frame(maxWidth: textMaxWidth, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
// Text determines bubble width; timestamp overlays at bottom-trailing.
// minWidth ensures the bubble is wide enough for the timestamp row.
Text(messageText)
.font(.system(size: 16, weight: .regular))
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.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)
}
} }
.frame(maxWidth: textMaxWidth, alignment: .trailing) .padding(.trailing, 14)
.padding(.bottom, 6)
} }
.padding(.horizontal, 14) .background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) }
.padding(.top, 8) .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
.padding(.bottom, 6) .frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
.background { .padding(.vertical, 1)
bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible)
}
.frame(maxWidth: maxBubbleWidth, alignment: .leading)
if !outgoing {
Spacer(minLength: 56)
}
}
.frame(maxWidth: .infinity)
.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: {
@@ -357,9 +381,9 @@ private extension ChatDetailView {
viewBox: CGSize(width: 21, height: 24), viewBox: CGSize(width: 21, height: 24),
color: Color.white color: Color.white
) )
.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,16 +402,14 @@ 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),
color: RosettaColors.Adaptive.textSecondary color: RosettaColors.Adaptive.textSecondary
) )
.frame(width: 19, height: 19) .frame(width: 19, height: 19)
.frame(width: 20, height: 36) .frame(width: 20, height: 36)
} }
.accessibilityLabel("Quick actions") .accessibilityLabel("Quick actions")
.buttonStyle(ChatDetailGlassPressButtonStyle()) .buttonStyle(ChatDetailGlassPressButtonStyle())
@@ -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:
.fill(.clear) Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
.glassEffect(.regular, in: .rect(cornerRadius: 21)) case .circle:
Circle().fill(.clear).glassEffect(.regular, in: .circle)
case let .rounded(radius):
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
}
} else { } else {
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous) let border = strokeColor.opacity(max(0.28, strokeOpacity))
shape switch shape {
.fill(.ultraThinMaterial) case .capsule:
.overlay( Capsule()
shape .fill(.ultraThinMaterial)
.stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8) .overlay(Capsule().stroke(border, lineWidth: 0.8))
) case .circle:
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 headerCapsuleBackground(strokeOpacity: Double) -> some View {
if #available(iOS 26.0, *) {
Color.clear
.glassEffect(.regular, in: .capsule)
} else {
Capsule()
.fill(.ultraThinMaterial)
.overlay(
Capsule()
.stroke(Color.white.opacity(strokeOpacity), lineWidth: 0.8)
)
}
}
@ViewBuilder
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,21 +656,29 @@ 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() {
DialogRepository.shared.ensureDialog( // Only update existing dialogs; don't create ghost entries from search.
opponentKey: route.publicKey, // New dialogs are created when messages are sent/received (SessionManager).
title: route.title, if dialogRepository.dialogs[route.publicKey] != nil {
username: route.username, DialogRepository.shared.ensureDialog(
verified: route.verified, opponentKey: route.publicKey,
myPublicKey: currentPublicKey title: route.title,
) username: route.username,
verified: route.verified,
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,12 +971,18 @@ private enum TelegramIconPath {
#Preview { #Preview {
ChatDetailView( NavigationStack {
route: ChatRoute( ZStack {
publicKey: "demo_public_key", Color.black.ignoresSafeArea()
title: "Demo User", ChatDetailView(
username: "demo", route: ChatRoute(
verified: 0 publicKey: "demo_public_key",
) title: "Demo User",
) username: "demo",
verified: 0
)
)
}
.preferredColorScheme(.dark)
}
} }

View File

@@ -67,10 +67,33 @@ struct ChatListView: View {
private extension ChatListView { private extension ChatListView {
@ViewBuilder @ViewBuilder
var normalContent: some View { var normalContent: some View {
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading { VStack(spacing: 0) {
ChatEmptyStateView(searchText: "") deviceVerificationBanners
} else {
dialogList if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
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) }
)
} }
} }
@@ -106,11 +129,13 @@ 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)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowSeparator(.visible) .listRowSeparator(.visible)
.listRowSeparatorTint(RosettaColors.Adaptive.divider) .listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 } .alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
@@ -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) }

View File

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

View File

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

View File

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

View File

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