Онлайн-статусы, исправление навигации и 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_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@@ -247,7 +247,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
@@ -262,12 +262,14 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = U6DMAKWNV3;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Rosetta;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
@@ -278,16 +280,17 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = rosetta.app.Rosetta;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
@@ -296,12 +299,14 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = U6DMAKWNV3;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Rosetta;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground;
@@ -312,16 +317,17 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = rosetta.app.Rosetta;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};

View File

@@ -183,11 +183,16 @@ final class DialogRepository {
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 }
if !title.isEmpty { dialog.opponentTitle = title }
if !username.isEmpty { dialog.opponentUsername = username }
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
schedulePersist()
}

View File

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

View File

@@ -37,8 +37,11 @@ final class SessionManager {
private let maxOutgoingRetryAttempts = 3
private let maxOutgoingWaitingLifetimeMs: Int64 = 80_000
private var userInfoSearchHandlerToken: UUID?
private init() {
setupProtocolCallbacks()
setupUserInfoSearchHandler()
}
// MARK: - Session Lifecycle
@@ -83,9 +86,13 @@ final class SessionManager {
/// Sends an encrypted message to a recipient, matching Android's outgoing flow.
func sendMessage(text: String, toPublicKey: String) async throws {
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
Self.logger.error("📤 Cannot send — missing keys")
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 timestamp = Int64(Date().timeIntervalSince1970 * 1000)
let packet = try makeOutgoingPacket(
@@ -97,10 +104,12 @@ final class SessionManager {
privateKeyHash: hash
)
// Use existing dialog title/username instead of overwriting with empty strings
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
DialogRepository.shared.ensureDialog(
opponentKey: toPublicKey,
title: "",
username: "",
title: existingDialog?.opponentTitle ?? "",
username: existingDialog?.opponentUsername ?? "",
myPublicKey: currentPublicKey
)
@@ -302,6 +311,11 @@ final class SessionManager {
// Android parity: request message synchronization after authentication.
self.requestSynchronize()
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)"
}
// 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) {
guard ProtocolManager.shared.connectionState == .authenticated else { return }
guard !syncRequestInFlight else { return }
@@ -601,6 +628,11 @@ final class SessionManager {
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?) {
guard let privateKeyHash else { return }
let normalized = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -615,6 +647,42 @@ final class SessionManager {
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(
text: String,
toPublicKey: String,

View File

@@ -38,16 +38,16 @@ struct AvatarView: View {
}
}
.frame(width: size, height: size)
.overlay(alignment: .bottomTrailing) {
.overlay(alignment: .bottomLeading) {
if isOnline {
Circle()
.fill(RosettaColors.online)
.fill(Color(hex: 0x4CD964))
.frame(width: badgeSize, height: badgeSize)
.overlay {
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)

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 {
if route.isSavedMessages {
return "Saved Messages"
}
if let dialog, !dialog.opponentTitle.isEmpty {
return dialog.opponentTitle
}
if !route.title.isEmpty {
return route.title
}
if let dialog, !dialog.opponentUsername.isEmpty {
return "@\(dialog.opponentUsername)"
}
if !route.username.isEmpty {
return "@\(route.username)"
}
if route.isSavedMessages { return "Saved Messages" }
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
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 {
if isTyping {
return "typing..."
}
if let dialog, dialog.isOnline {
return "online"
}
if let dialog, !dialog.opponentUsername.isEmpty {
return "@\(dialog.opponentUsername)"
}
if !route.username.isEmpty {
return "@\(route.username)"
}
return String(route.publicKey.prefix(12))
if route.isSavedMessages { return "" }
if isTyping { return "typing..." }
if let dialog, dialog.isOnline { return "online" }
return "offline"
}
private var trimmedMessage: String {
@@ -86,7 +73,7 @@ struct ChatDetailView: View {
private var sendButtonWidth: CGFloat { 38 }
private var sendButtonHeight: CGFloat { 36 }
private var composerHorizontalPadding: CGFloat {
private var composerTrailingPadding: CGFloat {
isInputFocused ? 16 : 28
}
@@ -98,47 +85,121 @@ struct ChatDetailView: View {
private static let scrollBottomAnchorId = "chat_detail_bottom_anchor"
var body: some View {
@ViewBuilder
private var content: some View {
GeometryReader { geometry in
ZStack {
RosettaColors.Adaptive.background
RosettaColors.Adaptive.background.ignoresSafeArea()
tiledChatBackground
.ignoresSafeArea()
messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140))
}
.safeAreaInset(edge: .top, spacing: 0) {
chatHeaderContainer
}
.safeAreaInset(edge: .bottom, spacing: 0) {
composer
}
.safeAreaInset(edge: .bottom, spacing: 0) { composer }
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.navigationBarBackButtonHidden(true) // скрываем стандартный back, но НЕ навбар
.enableSwipeBack()
.toolbarBackground(.visible, for: .navigationBar)
.applyGlassNavBar()
.toolbar { chatDetailToolbar } // твой header тут
.toolbar(.hidden, for: .tabBar)
.onAppear {
onPresentedChange?(true)
.task {
// 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()
markDialogAsRead()
// Subscribe to opponent's online status (Android parity) only after settled
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
}
.onDisappear {
onPresentedChange?(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 {
var avatarInitials: String {
if route.isSavedMessages {
return "S"
// 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)
}
@@ -150,82 +211,73 @@ private extension ChatDetailView {
RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E))
}
var chatHeader: some View {
HStack(spacing: 10) {
Button {
dismiss()
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: .white
/// Tiled chat background with properly scaled tiles (200pt wide)
private var tiledChatBackground: some View {
Group {
if let uiImage = UIImage(named: "ChatBackground"),
let cgImage = uiImage.cgImage {
let tileWidth: CGFloat = 200
let scaleFactor = uiImage.size.width / tileWidth
let scaledImage = UIImage(
cgImage: cgImage,
scale: uiImage.scale * scaleFactor,
orientation: .up
)
.frame(width: 11, height: 20)
.frame(width: 44, height: 44)
.background {
headerCircleBackground(strokeOpacity: 0.22)
}
}
.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)
Color(uiColor: UIColor(patternImage: scaledImage))
.opacity(0.18)
} else {
Color.clear
}
}
.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)
}
}
// MARK: - Messages
@ViewBuilder
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
let scroll = ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 6) {
@@ -235,19 +287,7 @@ private extension ChatDetailView {
maxBubbleWidth: maxBubbleWidth,
isTailVisible: isTailVisible(for: index)
)
.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)
.id(message.id)
}
Color.clear
@@ -259,17 +299,14 @@ private extension ChatDetailView {
.padding(.bottom, 10)
}
.scrollDismissesKeyboard(.interactively)
.onTapGesture {
isInputFocused = false
}
.onTapGesture { isInputFocused = false }
.onAppear {
DispatchQueue.main.async {
scrollToBottom(proxy: proxy, animated: false)
}
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(120))
scrollToBottom(proxy: proxy, animated: false)
}
markDialogAsRead()
}
.onChange(of: messages.count) { _, _ in
scrollToBottom(proxy: proxy, animated: true)
@@ -293,51 +330,38 @@ private extension ChatDetailView {
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, isTailVisible: Bool) -> some View {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
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) {
Text(messageTime(message.timestamp))
.font(.system(size: 12, weight: .regular))
.foregroundStyle(
outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary
)
.foregroundStyle(outgoing ? Color.white.opacity(0.72) : RosettaColors.Adaptive.textSecondary)
if outgoing {
deliveryIndicator(message.deliveryStatus)
}
if outgoing { deliveryIndicator(message.deliveryStatus) }
}
.frame(maxWidth: textMaxWidth, alignment: .trailing)
.padding(.trailing, 14)
.padding(.bottom, 6)
}
.padding(.horizontal, 14)
.padding(.top, 8)
.padding(.bottom, 6)
.background {
bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible)
}
.frame(maxWidth: maxBubbleWidth, alignment: .leading)
if !outgoing {
Spacer(minLength: 56)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 1)
.background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) }
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
.padding(.vertical, 1)
}
// MARK: - Composer
var composer: some View {
VStack(spacing: 6) {
if let sendError {
@@ -348,7 +372,7 @@ private extension ChatDetailView {
.padding(.horizontal, 16)
}
HStack(alignment: .bottom, spacing: shouldShowSendButton ? 0 : 6) {
HStack(alignment: .bottom, spacing: 0) {
Button {
// Placeholder for attachment picker
} label: {
@@ -357,9 +381,9 @@ private extension ChatDetailView {
viewBox: CGSize(width: 21, height: 24),
color: Color.white
)
.frame(width: 21, height: 24)
.frame(width: 42, height: 42)
.background { floatingCircleBackground(strokeOpacity: 0.18) }
.frame(width: 21, height: 24)
.frame(width: 42, height: 42)
.background { glass(shape: .circle, strokeOpacity: 0.18) }
}
.accessibilityLabel("Attach")
.buttonStyle(ChatDetailGlassPressButtonStyle())
@@ -378,16 +402,14 @@ private extension ChatDetailView {
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
HStack(alignment: .center, spacing: 0) {
Button {
// Placeholder for quick actions
} label: {
Button { } label: {
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.frame(width: 20, height: 36)
.frame(width: 19, height: 19)
.frame(width: 20, height: 36)
}
.accessibilityLabel("Quick actions")
.buttonStyle(ChatDetailGlassPressButtonStyle())
@@ -397,7 +419,6 @@ private extension ChatDetailView {
.overlay(alignment: .trailing) {
Button(action: sendCurrentMessage) {
ZStack {
// Mirrors the layered blend stack from the original SVG icon.
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
@@ -445,9 +466,7 @@ private extension ChatDetailView {
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
.frame(width: 22, height: 19)
.frame(width: sendButtonWidth, height: sendButtonHeight)
.background {
Capsule().fill(Color(hex: 0x008BFF))
}
.background { Capsule().fill(Color(hex: 0x008BFF)) }
}
.accessibilityLabel("Send")
.disabled(!canSend)
@@ -468,11 +487,8 @@ private extension ChatDetailView {
}
.padding(3)
.frame(minHeight: 42, alignment: .bottom)
.background {
floatingComposerInputBackground(
strokeOpacity: 0.18
)
}
.background { glass(shape: .rounded(21), strokeOpacity: 0.18) }
.padding(.leading, 6)
Button(action: trailingAction) {
TelegramVectorIcon(
@@ -482,7 +498,7 @@ private extension ChatDetailView {
)
.frame(width: 18, height: 24)
.frame(width: 42, height: 42)
.background { floatingCircleBackground(strokeOpacity: 0.18) }
.background { glass(shape: .circle, strokeOpacity: 0.18) }
}
.accessibilityLabel("Voice message")
.buttonStyle(ChatDetailGlassPressButtonStyle())
@@ -494,10 +510,12 @@ private extension ChatDetailView {
anchor: .trailing
)
.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()
}
.padding(.horizontal, composerHorizontalPadding)
.padding(.leading, 16)
.padding(.trailing, composerTrailingPadding)
.padding(.top, 4)
.padding(.bottom, isInputFocused ? 8 : 0)
.animation(composerAnimation, value: canSend)
@@ -507,6 +525,8 @@ private extension ChatDetailView {
.background(Color.clear)
}
// MARK: - Bubbles / Glass
@ViewBuilder
func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View {
let nearRadius: CGFloat = isTailVisible ? 6 : 17
@@ -529,102 +549,62 @@ private extension ChatDetailView {
}
}
@ViewBuilder
func floatingCapsuleBackground(strokeOpacity: Double) -> some View {
if #available(iOS 26.0, *) {
Color.clear
.glassEffect(.regular, in: .capsule)
} else {
Capsule()
.fill(.ultraThinMaterial)
.overlay(
Capsule()
.stroke(RosettaColors.Adaptive.border.opacity(max(0.28, strokeOpacity)), lineWidth: 0.8)
)
}
enum ChatGlassShape {
case capsule
case circle
case rounded(CGFloat)
}
@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, *) {
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
shape
.fill(.clear)
.glassEffect(.regular, in: .rect(cornerRadius: 21))
switch 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)
.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)
)
let border = strokeColor.opacity(max(0.28, strokeOpacity))
switch shape {
case .capsule:
Capsule()
.fill(.ultraThinMaterial)
.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
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)
)
}
}
// MARK: - Actions / utils
func trailingAction() {
if canSend {
sendCurrentMessage()
} else {
isInputFocused = true
}
if canSend { sendCurrentMessage() }
else { isInputFocused = true }
}
func deliveryTint(_ status: DeliveryStatus) -> Color {
switch status {
case .read:
return Color(hex: 0xA4E2FF)
case .delivered:
return Color.white.opacity(0.94)
case .error:
return RosettaColors.error
default:
return Color.white.opacity(0.78)
case .read: return Color(hex: 0xA4E2FF)
case .delivered: return Color.white.opacity(0.94)
case .error: return RosettaColors.error
default: return Color.white.opacity(0.78)
}
}
@@ -642,8 +622,7 @@ private extension ChatDetailView {
switch status {
case .read:
ZStack {
Image(systemName: "checkmark")
.offset(x: 3)
Image(systemName: "checkmark").offset(x: 3)
Image(systemName: "checkmark")
}
.font(.system(size: 10.5, weight: .semibold))
@@ -677,21 +656,29 @@ private extension ChatDetailView {
let next = messages[index + 1]
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 nextIsPlainText = next.attachments.isEmpty
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() {
DialogRepository.shared.ensureDialog(
opponentKey: route.publicKey,
title: route.title,
username: route.username,
verified: route.verified,
myPublicKey: currentPublicKey
)
// 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(
opponentKey: route.publicKey,
title: route.title,
username: route.username,
verified: route.verified,
myPublicKey: currentPublicKey
)
}
messageRepository.setDialogActive(route.publicKey, isActive: true)
}
@@ -727,6 +714,8 @@ private extension ChatDetailView {
}()
}
// MARK: - Button Styles
private struct ChatDetailGlassPressButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
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 {
let pathData: String
let viewBox: CGSize
@@ -760,15 +776,10 @@ private struct SVGPathShape: Shape {
let viewBox: CGSize
func path(in rect: CGRect) -> Path {
guard viewBox.width > 0, viewBox.height > 0 else {
return Path()
}
guard viewBox.width > 0, viewBox.height > 0 else { return Path() }
var parser = SVGPathParser(pathData: pathData)
var output = Path(parser.parse())
output = output.applying(
.init(scaleX: rect.width / viewBox.width, y: rect.height / viewBox.height)
)
output = output.applying(.init(scaleX: rect.width / viewBox.width, y: rect.height / viewBox.height))
return output
}
}
@@ -787,42 +798,22 @@ private struct SVGPathTokenizer {
while index < chars.count {
let ch = chars[index]
if ch.isWhitespace || ch == "," {
index += 1
continue
}
if ch.isLetter {
tokens.append(.command(ch))
index += 1
continue
}
if ch.isWhitespace || ch == "," { index += 1; continue }
if ch.isLetter { tokens.append(.command(ch)); index += 1; continue }
if ch.isNumber || ch == "-" || ch == "+" || ch == "." {
let start = index
index += 1
while index < chars.count {
let c = chars[index]
let prev = chars[index - 1]
if c.isNumber || c == "." || c == "e" || c == "E" {
index += 1
continue
}
if (c == "-" || c == "+"), (prev == "e" || prev == "E") {
index += 1
continue
}
if c.isNumber || c == "." || c == "e" || c == "E" { index += 1; continue }
if (c == "-" || c == "+"), (prev == "e" || prev == "E") { index += 1; continue }
break
}
let fragment = String(chars[start..<index])
if let value = Double(fragment) {
tokens.append(.number(CGFloat(value)))
}
if let value = Double(fragment) { tokens.append(.number(CGFloat(value))) }
continue
}
@@ -849,16 +840,11 @@ private struct SVGPathParser {
while index < tokens.count {
let command = readCommandOrReuse()
switch command {
case "M", "m":
parseMove(command)
case "L", "l":
parseLine(command)
case "H", "h":
parseHorizontal(command)
case "V", "v":
parseVertical(command)
case "C", "c":
parseCubic(command)
case "M", "m": parseMove(command)
case "L", "l": parseLine(command)
case "H", "h": parseHorizontal(command)
case "V", "v": parseVertical(command)
case "C", "c": parseCubic(command)
case "Z", "z":
cgPath.closeSubpath()
current = subpathStart
@@ -871,9 +857,7 @@ private struct SVGPathParser {
private var isAtCommand: Bool {
guard index < tokens.count else { return false }
if case .command = tokens[index] {
return true
}
if case .command = tokens[index] { return true }
return false
}
@@ -897,10 +881,7 @@ private struct SVGPathParser {
}
private func resolvedPoint(x: CGFloat, y: CGFloat, relative: Bool) -> CGPoint {
if relative {
return CGPoint(x: current.x + x, y: current.y + y)
}
return CGPoint(x: x, y: y)
relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y)
}
private mutating func readPoint(relative: Bool) -> CGPoint? {
@@ -934,10 +915,7 @@ private struct SVGPathParser {
private mutating func parseHorizontal(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let value = readNumber() {
current = CGPoint(
x: relative ? current.x + value : value,
y: current.y
)
current = CGPoint(x: relative ? current.x + value : value, y: current.y)
cgPath.addLine(to: current)
}
}
@@ -945,10 +923,7 @@ private struct SVGPathParser {
private mutating func parseVertical(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let value = readNumber() {
current = CGPoint(
x: current.x,
y: relative ? current.y + value : value
)
current = CGPoint(x: current.x, y: relative ? current.y + value : value)
cgPath.addLine(to: current)
}
}
@@ -975,14 +950,13 @@ private struct SVGPathParser {
private mutating func skipToNextCommand() {
while index < tokens.count {
if case .command = tokens[index] {
return
}
if case .command = tokens[index] { return }
index += 1
}
}
}
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"#
@@ -997,12 +971,18 @@ private enum TelegramIconPath {
#Preview {
ChatDetailView(
route: ChatRoute(
publicKey: "demo_public_key",
title: "Demo User",
username: "demo",
verified: 0
)
)
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
ChatDetailView(
route: ChatRoute(
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 {
@ViewBuilder
var normalContent: some View {
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
dialogList
VStack(spacing: 0) {
deviceVerificationBanners
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 {
NavigationLink(value: ChatRoute(dialog: dialog)) {
Button {
navigationPath.append(ChatRoute(dialog: dialog))
} label: {
ChatRowView(dialog: dialog)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
.listRowInsets(EdgeInsets())
.listRowSeparator(.visible)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.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) }

View File

@@ -18,6 +18,7 @@ final class ChatListViewModel: ObservableObject {
@Published var recentSearches: [RecentSearch] = []
private var searchTask: Task<Void, Never>?
private var searchRetryTask: Task<Void, Never>?
private var lastSearchedText = ""
private var searchHandlerToken: UUID?
private var recentSearchesCancellable: AnyCancellable?
@@ -107,7 +108,8 @@ final class ChatListViewModel: ObservableObject {
publicKey: user.publicKey,
title: user.title,
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 {
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
}
@@ -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 {
input.replacingOccurrences(of: "@", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -2,17 +2,31 @@ import SwiftUI
// MARK: - ChatRowView
/// Chat row matching Figma "Row - Chats" component spec:
/// Row: height 78, paddingLeft 10, paddingRight 16, vertical center
/// Avatar: 62px circle, 10pt trailing padding
/// Title: SF Pro Medium 17pt, tracking -0.43, primary color
/// Message: SF Pro Regular 15pt, tracking -0.23, secondary color
/// Time: SF Pro Regular 14pt, tracking -0.23, secondary color
/// Badges gap: 6pt verified 12px, muted 12px
/// Trailing: pt 8, pb 14 readStatus + time (gap 2), pin/count at bottom
/// Chat row matching Figma "Row - Chats" component spec (node 3994:38947):
///
/// Row: height 78, pl-10, pr-16, items-center
/// Avatar: 62px circle, pr-10
/// Contents: flex-col, h-full, items-start, justify-center, pb-px
/// Title and Trailing Accessories: flex-1, gap-6, items-center, w-full
/// Title and Detail: flex-1, h-63, items-start, overflow-clip
/// 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 {
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 {
HStack(spacing: 0) {
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 {
var contentSection: some View {
HStack(alignment: .center, spacing: 6) {
// Left column: title + message
VStack(alignment: .leading, spacing: 2) {
// "Title and Detail": flex-1, h-63, items-start, overflow-clip
VStack(alignment: .leading, spacing: 0) {
titleRow
messageRow
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 63)
.clipped()
// Right column: time + pin/badge
// "Accessories and Grabber": h-full, items-center, justify-end
trailingColumn
.frame(maxHeight: .infinity)
}
.frame(height: 63)
.frame(maxHeight: .infinity)
.padding(.bottom, 1)
}
}
// MARK: - Title Row (name + badges)
// Figma "Title": gap-4, items-center, w-full
private extension ChatRowView {
var titleRow: some View {
HStack(spacing: 4) {
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
Text(displayTitle)
.font(.system(size: 17, weight: .medium))
.tracking(-0.43)
.foregroundStyle(RosettaColors.Adaptive.text)
@@ -89,6 +109,7 @@ private extension ChatRowView {
}
// MARK: - Message Row
// Figma "Message": h-41, SF Pro Regular 15/20, tracking -0.23, secondary
private extension ChatRowView {
var messageRow: some View {
@@ -96,7 +117,8 @@ private extension ChatRowView {
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
.lineLimit(2)
.frame(height: 41, alignment: .topLeading)
}
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 {
var trailingColumn: some View {
@@ -127,7 +152,7 @@ private extension ChatRowView {
: RosettaColors.Adaptive.textSecondary
)
}
.padding(.top, 2)
.padding(.top, 8)
Spacer(minLength: 0)
@@ -144,7 +169,7 @@ private extension ChatRowView {
unreadBadge
}
}
.padding(.bottom, 2)
.padding(.bottom, 14)
}
}
@@ -181,14 +206,18 @@ private extension ChatRowView {
let count = dialog.unreadCount
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
let isMuted = dialog.isMuted
let isSmall = count < 10
return Text(text)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(.white)
.padding(.horizontal, 4)
.frame(minWidth: 20, minHeight: 20)
.frame(maxWidth: 37)
.foregroundStyle(.black)
.padding(.horizontal, isSmall ? 0 : 4)
.frame(
minWidth: 20,
maxWidth: isSmall ? 20 : 37,
minHeight: 20
)
.background {
Capsule()
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)

View File

@@ -126,7 +126,8 @@ final class SearchViewModel {
publicKey: user.publicKey,
title: user.title,
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 .connected: return "Connected"
case .handshaking: return "Authenticating..."
case .deviceVerificationRequired: return "Device Verification Required"
case .authenticated: return "Online"
}
}