Онлайн-статусы, исправление навигации и UI чатов
- Реализован PacketOnlineSubscribe (0x04) для подписки на статус собеседника - Онлайн-статус загружается из результатов поиска (PacketSearch) при каждом хэндшейке - Toolbar capsule показывает online/offline/typing вместо @username - Зелёная точка онлайн-индикатора на аватаре в списке чатов (bottom-left, как в Android) - Убрана точка с аватара в toolbar (статус отображается текстом) - Исправлен баг двойного тапа при входе в чат (программная навигация вместо NavigationLink) - DialogRepository.updateUserInfo теперь принимает и сохраняет online-статус - Очистка requestedUserInfoKeys при реконнекте для обновления статусов - Добавлено логирование результатов поиска и отправки пакетов Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -190,7 +190,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - DeviceResolveSolution
|
||||
|
||||
enum DeviceResolveSolution: Int {
|
||||
case accept = 0
|
||||
case decline = 1
|
||||
}
|
||||
|
||||
// MARK: - PacketDeviceResolve (0x18)
|
||||
|
||||
/// Sent to accept or decline a pending device login request.
|
||||
/// Also received from server when another device approves/declines this device.
|
||||
struct PacketDeviceResolve: Packet {
|
||||
static let packetId = 0x18
|
||||
|
||||
var deviceId: String = ""
|
||||
var solution: DeviceResolveSolution = .decline
|
||||
|
||||
func write(to stream: Stream) {
|
||||
stream.writeString(deviceId)
|
||||
stream.writeInt8(solution.rawValue)
|
||||
}
|
||||
|
||||
mutating func read(from stream: Stream) {
|
||||
deviceId = stream.readString()
|
||||
solution = DeviceResolveSolution(rawValue: stream.readInt8()) ?? .decline
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ struct PacketHandshake: Packet {
|
||||
var protocolVersion: Int = 1
|
||||
var heartbeatInterval: Int = 15
|
||||
var device = HandshakeDevice()
|
||||
var handshakeState: HandshakeState = .needDeviceVerification
|
||||
var handshakeState: HandshakeState = .completed
|
||||
|
||||
func write(to stream: Stream) {
|
||||
stream.writeString(privateKey)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
/// OnlineSubscribe packet (0x04) — subscribe to a user's online status updates.
|
||||
/// Client sends this for each dialog opponent to receive PacketOnlineState (0x05) updates.
|
||||
struct PacketOnlineSubscribe: Packet {
|
||||
static let packetId = 0x04
|
||||
|
||||
var publicKey: String = ""
|
||||
|
||||
func write(to stream: Stream) {
|
||||
stream.writeString(publicKey)
|
||||
}
|
||||
|
||||
mutating func read(from stream: Stream) {
|
||||
publicKey = stream.readString()
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ enum ConnectionState: String {
|
||||
case connecting
|
||||
case 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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
28
Rosetta/DesignSystem/Components/SwipeBackModifier.swift
Normal file
28
Rosetta/DesignSystem/Components/SwipeBackModifier.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Re-enables the iOS interactive swipe-back gesture when
|
||||
/// `.navigationBarBackButtonHidden(true)` is used in SwiftUI.
|
||||
struct SwipeBackGestureEnabler: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
SwipeBackController()
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
|
||||
private final class SwipeBackController: UIViewController {
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if let nav = navigationController {
|
||||
nav.interactivePopGestureRecognizer?.isEnabled = true
|
||||
nav.interactivePopGestureRecognizer?.delegate = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Restores the swipe-back gesture after hiding the default back button.
|
||||
func enableSwipeBack() -> some View {
|
||||
background(SwipeBackGestureEnabler())
|
||||
}
|
||||
}
|
||||
@@ -29,38 +29,25 @@ struct ChatDetailView: View {
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatDetailView {
|
||||
var avatarInitials: String {
|
||||
if route.isSavedMessages {
|
||||
return "S"
|
||||
var body: some View { content }
|
||||
}
|
||||
|
||||
private extension ChatDetailView {
|
||||
// MARK: - Toolbar (как в ChatListView)
|
||||
|
||||
@ToolbarContentBuilder
|
||||
var chatDetailToolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { dismiss() } label: { backCircleButtonLabel }
|
||||
.frame(width: 36, height: 36)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
.accessibilityLabel("Back")
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
Button { dismiss() } label: {
|
||||
VStack(spacing: 1) {
|
||||
HStack(spacing: 3) {
|
||||
Text(titleText)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
if !route.isSavedMessages && effectiveVerified > 0 {
|
||||
VerifiedBadge(verified: effectiveVerified, size: 12)
|
||||
}
|
||||
}
|
||||
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(
|
||||
isTyping || (dialog?.isOnline == true)
|
||||
? RosettaColors.online
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
AvatarView(
|
||||
initials: avatarInitials,
|
||||
colorIndex: avatarColorIndex,
|
||||
size: 35,
|
||||
isOnline: false,
|
||||
isSavedMessages: route.isSavedMessages
|
||||
)
|
||||
.frame(width: 36, height: 36)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var backCircleButtonLabel: some View {
|
||||
ZStack {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.backChevron,
|
||||
viewBox: CGSize(width: 11, height: 20),
|
||||
color: .white
|
||||
)
|
||||
.frame(width: 11, height: 20)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(width: 36, height: 36) // iOS hit-area
|
||||
}
|
||||
|
||||
// MARK: - Existing helpers / UI
|
||||
|
||||
var avatarInitials: String {
|
||||
if route.isSavedMessages { return "S" }
|
||||
return RosettaColors.initials(name: titleText, publicKey: route.publicKey)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Color(uiColor: UIColor(patternImage: scaledImage))
|
||||
.opacity(0.18)
|
||||
} else {
|
||||
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
|
||||
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) {
|
||||
@@ -238,18 +290,6 @@ private extension ChatDetailView {
|
||||
.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
|
||||
.frame(height: 1)
|
||||
.id(Self.scrollBottomAnchorId)
|
||||
@@ -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 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)
|
||||
.frame(maxWidth: textMaxWidth, alignment: .leading)
|
||||
.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(.horizontal, 14)
|
||||
.padding(.top, 8)
|
||||
.padding(.trailing, 14)
|
||||
.padding(.bottom, 6)
|
||||
.background {
|
||||
bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: .leading)
|
||||
|
||||
if !outgoing {
|
||||
Spacer(minLength: 56)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.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: {
|
||||
@@ -359,7 +383,7 @@ private extension ChatDetailView {
|
||||
)
|
||||
.frame(width: 21, height: 24)
|
||||
.frame(width: 42, height: 42)
|
||||
.background { floatingCircleBackground(strokeOpacity: 0.18) }
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.18) }
|
||||
}
|
||||
.accessibilityLabel("Attach")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
@@ -378,9 +402,7 @@ 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),
|
||||
@@ -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
|
||||
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: .rect(cornerRadius: 21))
|
||||
} 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)
|
||||
)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func headerCapsuleBackground(strokeOpacity: Double) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Color.clear
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
let border = strokeColor.opacity(max(0.28, strokeOpacity))
|
||||
switch shape {
|
||||
case .capsule:
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color.white.opacity(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
|
||||
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,14 +656,21 @@ 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() {
|
||||
// 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,
|
||||
@@ -692,6 +678,7 @@ private extension ChatDetailView {
|
||||
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,6 +971,9 @@ private enum TelegramIconPath {
|
||||
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
ChatDetailView(
|
||||
route: ChatRoute(
|
||||
publicKey: "demo_public_key",
|
||||
@@ -1006,3 +983,6 @@ private enum TelegramIconPath {
|
||||
)
|
||||
)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,12 +67,35 @@ struct ChatListView: View {
|
||||
private extension ChatListView {
|
||||
@ViewBuilder
|
||||
var normalContent: some View {
|
||||
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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var dialogList: some View {
|
||||
List {
|
||||
@@ -106,7 +129,9 @@ 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)
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user