Исправление расширения поля пароля при переключении видимости: перенос toggle в UIKit

This commit is contained in:
2026-03-12 04:01:21 +05:00
parent dc8e179c10
commit 70deaaf7f7
23 changed files with 706 additions and 285 deletions

View File

@@ -8,15 +8,21 @@ final class DialogRepository {
static let shared = DialogRepository()
private(set) var dialogs: [String: Dialog] = [:]
private(set) var dialogs: [String: Dialog] = [:] {
didSet { _sortedDialogsCache = nil }
}
private var currentAccount: String = ""
private var persistTask: Task<Void, Never>?
private var _sortedDialogsCache: [Dialog]?
var sortedDialogs: [Dialog] {
Array(dialogs.values).sorted {
if let cached = _sortedDialogsCache { return cached }
let sorted = Array(dialogs.values).sorted {
if $0.isPinned != $1.isPinned { return $0.isPinned }
return $0.lastMessageTimestamp > $1.lastMessageTimestamp
}
_sortedDialogsCache = sorted
return sorted
}
private init() {}
@@ -72,7 +78,9 @@ final class DialogRepository {
}
/// Creates or updates a dialog from an incoming message packet.
func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) {
/// - Parameter fromSync: When `true`, outgoing messages are marked as `.delivered`
/// because the server already processed them delivery ACKs will never arrive again.
func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String, fromSync: Bool = false) {
if currentAccount.isEmpty {
currentAccount = myPublicKey
}
@@ -101,7 +109,7 @@ final class DialogRepository {
dialog.lastMessage = decryptedText
dialog.lastMessageTimestamp = normalizeTimestamp(packet.timestamp)
dialog.lastMessageFromMe = fromMe
dialog.lastMessageDelivered = fromMe ? .waiting : .delivered
dialog.lastMessageDelivered = fromMe ? (fromSync ? .delivered : .waiting) : .delivered
if fromMe {
dialog.iHaveSent = true
@@ -229,6 +237,26 @@ final class DialogRepository {
schedulePersist()
}
/// Update dialog metadata after a single message was deleted.
/// If no messages remain, the dialog is removed entirely.
func reconcileAfterMessageDelete(opponentKey: String) {
let messages = MessageRepository.shared.messages(for: opponentKey)
guard var dialog = dialogs[opponentKey] else { return }
guard let lastMsg = messages.last else {
dialogs.removeValue(forKey: opponentKey)
schedulePersist()
return
}
dialog.lastMessage = lastMsg.text
dialog.lastMessageTimestamp = lastMsg.timestamp
dialog.lastMessageFromMe = lastMsg.fromPublicKey == currentAccount
dialog.lastMessageDelivered = lastMsg.deliveryStatus
dialogs[opponentKey] = dialog
schedulePersist()
}
/// Desktop parity: check last N messages to determine if dialog should be a request.
/// If none of the last `dialogDropToRequestsMessageCount` messages are from me,
/// and the dialog is not a system account, mark as request (`iHaveSent = false`).
@@ -280,6 +308,29 @@ final class DialogRepository {
schedulePersist()
}
/// Desktop parity: reconcile dialog-level `lastMessageDelivered` with the actual
/// delivery status of the last message in MessageRepository.
/// Called after sync completes to fix any divergence accumulated during batch processing.
func reconcileDeliveryStatuses() {
var changed = false
for (opponentKey, dialog) in dialogs {
guard dialog.lastMessageFromMe else { continue }
let messages = MessageRepository.shared.messages(for: opponentKey)
guard let lastMessage = messages.last,
lastMessage.fromPublicKey == currentAccount else { continue }
let realStatus = lastMessage.deliveryStatus
if dialog.lastMessageDelivered != realStatus {
var updated = dialog
updated.lastMessageDelivered = realStatus
dialogs[opponentKey] = updated
changed = true
}
}
if changed {
schedulePersist()
}
}
func toggleMute(opponentKey: String) {
guard var dialog = dialogs[opponentKey] else { return }
dialog.isMuted.toggle()

View File

@@ -8,8 +8,8 @@ final class MessageRepository: ObservableObject {
// Desktop parity: MESSAGE_MAX_LOADED = 40 per dialog.
private let maxMessagesPerDialog = ProtocolConstants.messageMaxCached
@Published private var messagesByDialog: [String: [ChatMessage]] = [:]
@Published private var typingDialogs: Set<String> = []
@Published private(set) var messagesByDialog: [String: [ChatMessage]] = [:]
@Published private(set) var typingDialogs: Set<String> = []
private var activeDialogs: Set<String> = []
private var messageToDialog: [String: String] = [:]
@@ -97,12 +97,17 @@ final class MessageRepository: ObservableObject {
// MARK: - Message Updates
func upsertFromMessagePacket(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) {
/// - Parameter fromSync: When `true`, outgoing messages are created as `.delivered`
/// because the server already processed them during sync ACKs will never arrive again.
func upsertFromMessagePacket(_ packet: PacketMessage, myPublicKey: String, decryptedText: String, fromSync: Bool = false) {
let fromMe = packet.fromPublicKey == myPublicKey
let dialogKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId
let timestamp = normalizeTimestamp(packet.timestamp)
let incomingRead = !fromMe && activeDialogs.contains(dialogKey)
// Sync-originated outgoing messages: server already delivered them,
// no ACK will arrive treat as .delivered immediately.
let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered)
messageToDialog[messageId] = dialogKey
@@ -112,7 +117,7 @@ final class MessageRepository: ObservableObject {
messages[existingIndex].timestamp = timestamp
messages[existingIndex].attachments = packet.attachments
if fromMe, messages[existingIndex].deliveryStatus == .error {
messages[existingIndex].deliveryStatus = .waiting
messages[existingIndex].deliveryStatus = fromSync ? .delivered : .waiting
}
if incomingRead {
messages[existingIndex].isRead = true
@@ -127,7 +132,7 @@ final class MessageRepository: ObservableObject {
toPublicKey: packet.toPublicKey,
text: decryptedText,
timestamp: timestamp,
deliveryStatus: fromMe ? .waiting : .delivered,
deliveryStatus: outgoingStatus,
isRead: incomingRead || fromMe,
attachments: packet.attachments
)
@@ -199,12 +204,15 @@ final class MessageRepository: ObservableObject {
}
func deleteDialog(_ dialogKey: String) {
guard messagesByDialog.removeValue(forKey: dialogKey) != nil else { return }
guard let removedMessages = messagesByDialog.removeValue(forKey: dialogKey) else { return }
activeDialogs.remove(dialogKey)
typingDialogs.remove(dialogKey)
typingResetTasks[dialogKey]?.cancel()
typingResetTasks[dialogKey] = nil
messageToDialog = messageToDialog.filter { $0.value != dialogKey }
// O(k) where k = messages in this dialog (max 40), instead of O(n) for all messages
for message in removedMessages {
messageToDialog.removeValue(forKey: message.id)
}
schedulePersist()
}
@@ -251,6 +259,14 @@ final class MessageRepository: ObservableObject {
return (retryable: retryable, expired: expired)
}
/// Delete a single message by ID and persist the change.
func deleteMessage(id: String) {
guard let dialogKey = messageToDialog.removeValue(forKey: id) else { return }
updateMessages(for: dialogKey) { messages in
messages.removeAll { $0.id == id }
}
}
func reset(clearPersisted: Bool = false) {
persistTask?.cancel()
persistTask = nil
@@ -278,12 +294,16 @@ final class MessageRepository: ObservableObject {
private func updateMessages(for dialogKey: String, mutate: (inout [ChatMessage]) -> Void) {
var messages = messagesByDialog[dialogKey] ?? []
let countBefore = messages.count
mutate(&messages)
messages.sort {
if $0.timestamp != $1.timestamp {
return $0.timestamp < $1.timestamp
// Only sort when messages were added/removed; skip for in-place updates (delivery status, read)
if messages.count != countBefore {
messages.sort {
if $0.timestamp != $1.timestamp {
return $0.timestamp < $1.timestamp
}
return $0.id < $1.id
}
return $0.id < $1.id
}
if messages.count > maxMessagesPerDialog {
let overflow = messages.count - maxMessagesPerDialog

View File

@@ -1,17 +1,24 @@
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.
/// Desktop parity: privateKey + int16(count) + publicKey[].
struct PacketOnlineSubscribe: Packet {
static let packetId = 0x04
var publicKey: String = ""
var privateKey: String = ""
var publicKeys: [String] = []
func write(to stream: Stream) {
stream.writeString(publicKey)
stream.writeString(privateKey)
stream.writeInt16(publicKeys.count)
for key in publicKeys {
stream.writeString(key)
}
}
mutating func read(from stream: Stream) {
publicKey = stream.readString()
privateKey = stream.readString()
let count = stream.readInt16()
publicKeys = (0..<count).map { _ in stream.readString() }
}
}

View File

@@ -14,6 +14,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
private var hasNotifiedConnected = false
private(set) var isConnected = false
private var disconnectHandledForCurrentSocket = false
private var reconnectAttempt = 0
var onConnected: (() -> Void)?
var onDisconnected: ((Error?) -> Void)?
@@ -88,6 +89,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
hasNotifiedConnected = true
isConnected = true
disconnectHandledForCurrentSocket = false
reconnectAttempt = 0
reconnectTask?.cancel()
reconnectTask = nil
onConnected?()
@@ -140,9 +142,13 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return }
guard reconnectTask == nil else { return }
let attempt = reconnectAttempt
reconnectAttempt += 1
// Exponential backoff: 5s, 7.5s, 11.25s, ... capped at 30s
let delaySeconds = min(5.0 * pow(1.5, Double(attempt)), 30.0)
reconnectTask = Task { [weak self] in
Self.logger.info("Reconnecting in 5 seconds...")
try? await Task.sleep(nanoseconds: 5_000_000_000)
Self.logger.info("Reconnecting in \(String(format: "%.1f", delaySeconds))s (attempt \(attempt + 1))...")
try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
self.reconnectTask = nil
self.connect()

View File

@@ -142,16 +142,29 @@ final class SessionManager {
myPublicKey: currentPublicKey
)
// Optimistic UI update show message immediately as "waiting"
// Desktop parity: if not connected, set initial status to ERROR (not WAITING).
// The packet is still queued and will be sent on reconnect;
// delivery ACK will update status to DELIVERED.
let isConnected = connState == .authenticated
let offlineAsSend = !isConnected
// Optimistic UI update
DialogRepository.shared.updateFromMessage(
packet, myPublicKey: currentPublicKey, decryptedText: text
packet, myPublicKey: currentPublicKey, decryptedText: text, fromSync: offlineAsSend
)
MessageRepository.shared.upsertFromMessagePacket(
packet,
myPublicKey: currentPublicKey,
decryptedText: text
decryptedText: text,
fromSync: offlineAsSend
)
// Desktop parity: mark as ERROR when offline (fromSync sets .delivered, override to .error)
if offlineAsSend {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
}
// Saved Messages: local-only, no server send
if toPublicKey == currentPublicKey {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
@@ -159,7 +172,7 @@ final class SessionManager {
return
}
// Send via WebSocket
// Send via WebSocket (queued if offline, sent directly if online)
ProtocolManager.shared.sendPacket(packet)
registerOutgoingRetry(for: packet)
}
@@ -364,6 +377,13 @@ final class SessionManager {
self.pendingIncomingMessages.removeAll()
self.isProcessingIncomingMessages = false
// Cancel stale retry timers from previous connection
// they would fire and duplicate-send messages that are about to be retried fresh.
self.pendingOutgoingRetryTasks.values.forEach { $0.cancel() }
self.pendingOutgoingRetryTasks.removeAll()
self.pendingOutgoingPackets.removeAll()
self.pendingOutgoingAttempts.removeAll()
// Desktop parity: request message synchronization after authentication.
self.requestSynchronize()
self.retryWaitingOutgoingMessagesAfterReconnect()
@@ -375,14 +395,9 @@ final class SessionManager {
// Send push token to server for push notifications (Android parity).
self.sendPushTokenToServer()
// Desktop parity: proactively fetch user info (names, online status)
// for all dialogs. Desktop does this per-component via useUserInformation;
// we do it in bulk after handshake with staggered sends.
Task { @MainActor [weak self] in
// Small delay so sync packets go first
try? await Task.sleep(for: .milliseconds(500))
await self?.refreshOnlineStatusForAllDialogs()
}
// Desktop parity: user info refresh is deferred until sync completes.
// Desktop fetches lazily per-component (useUserInformation); we do it
// in bulk after sync ends to avoid flooding the server during sync streaming.
}
}
@@ -422,7 +437,13 @@ final class SessionManager {
Self.logger.debug("SYNC stopped after stalled batches")
self.syncBatchInProgress = false
self.flushPendingReadReceipts()
DialogRepository.shared.reconcileDeliveryStatuses()
self.stalledSyncBatchCount = 0
// Refresh user info now that sync is done (desktop parity: lazy per-component).
Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(300))
await self?.refreshOnlineStatusForAllDialogs()
}
return
}
@@ -432,8 +453,14 @@ final class SessionManager {
case .notNeeded:
self.syncBatchInProgress = false
self.flushPendingReadReceipts()
DialogRepository.shared.reconcileDeliveryStatuses()
self.stalledSyncBatchCount = 0
Self.logger.debug("SYNC NOT_NEEDED")
// Refresh user info now that sync is done.
Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(300))
await self?.refreshOnlineStatusForAllDialogs()
}
}
}
}
@@ -460,6 +487,7 @@ final class SessionManager {
}
}
isProcessingIncomingMessages = false
signalQueueDrained()
}
private func processIncomingMessage(_ packet: PacketMessage) async {
@@ -492,12 +520,13 @@ final class SessionManager {
}
DialogRepository.shared.updateFromMessage(
packet, myPublicKey: myKey, decryptedText: text
packet, myPublicKey: myKey, decryptedText: text, fromSync: syncBatchInProgress
)
MessageRepository.shared.upsertFromMessagePacket(
packet,
myPublicKey: myKey,
decryptedText: text
decryptedText: text,
fromSync: syncBatchInProgress
)
let dialog = DialogRepository.shared.dialogs[opponentKey]
@@ -513,8 +542,10 @@ final class SessionManager {
// that triggered server RST disconnects.
// Desktop parity: only mark as read if user is NOT idle AND app is in foreground.
// Desktop also skips system accounts and blocked users.
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
let shouldMarkRead = dialogIsActive && !isUserIdle && isAppInForeground
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
let shouldMarkRead = dialogIsActive && !isUserIdle && isAppInForeground && !isSystem
if shouldMarkRead {
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
@@ -541,18 +572,41 @@ final class SessionManager {
}
}
private func waitForInboundQueueToDrain(timeoutMs: UInt64 = 5_000) async -> Bool {
let started = DispatchTime.now().uptimeNanoseconds
let timeoutNs = timeoutMs * 1_000_000
/// Continuations waiting for the inbound queue to drain.
private var drainContinuations: [CheckedContinuation<Void, Never>] = []
while isProcessingIncomingMessages || !pendingIncomingMessages.isEmpty {
if DispatchTime.now().uptimeNanoseconds - started >= timeoutNs {
return false
}
try? await Task.sleep(for: .milliseconds(20))
/// Signal all waiting continuations that the queue has drained.
private func signalQueueDrained() {
let waiting = drainContinuations
drainContinuations.removeAll()
for continuation in waiting {
continuation.resume()
}
}
private func waitForInboundQueueToDrain(timeoutMs: UInt64 = 5_000) async -> Bool {
// Fast path: already drained
if !isProcessingIncomingMessages && pendingIncomingMessages.isEmpty {
return true
}
return true
// Event-based: wait for signal or timeout
let drained = await withTaskGroup(of: Bool.self) { group in
group.addTask { @MainActor in
await withCheckedContinuation { continuation in
self.drainContinuations.append(continuation)
}
return true
}
group.addTask {
try? await Task.sleep(for: .milliseconds(timeoutMs))
return false
}
let result = await group.next() ?? false
group.cancelAll()
return result
}
return drained
}
private var syncCursorKey: String {
@@ -566,11 +620,13 @@ final class SessionManager {
func subscribeToOnlineStatus(publicKey: String) {
guard !publicKey.isEmpty,
ProtocolManager.shared.connectionState == .authenticated,
!onlineSubscribedKeys.contains(publicKey)
!onlineSubscribedKeys.contains(publicKey),
let hash = privateKeyHash
else { return }
onlineSubscribedKeys.insert(publicKey)
var packet = PacketOnlineSubscribe()
packet.publicKey = publicKey
packet.privateKey = hash
packet.publicKeys = [publicKey]
ProtocolManager.shared.sendPacket(packet)
}
@@ -719,10 +775,9 @@ final class SessionManager {
ProtocolManager.shared.sendPacket(searchPacket)
}
/// After handshake, request user info for all existing dialog opponents.
/// Desktop parity: useUserInformation sends PacketSearch(publicKey) for every user
/// not in cache. We do the same in bulk empty-title dialogs first (names missing),
/// then the rest (online status refresh).
/// Request user info for all existing dialog opponents after sync completes.
/// Desktop parity: useUserInformation sends PacketSearch(publicKey) lazily per-component.
/// We do it in bulk after sync with generous staggering to avoid server rate-limiting.
private func refreshOnlineStatusForAllDialogs() async {
let dialogs = DialogRepository.shared.dialogs
let ownKey = currentPublicKey
@@ -739,24 +794,24 @@ final class SessionManager {
}
}
// Priority: fetch missing names first
// Priority: fetch missing names first (generous 200ms stagger)
var count = 0
for key in missingName {
guard ProtocolManager.shared.connectionState == .authenticated else { break }
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
count += 1
if count > 1 {
try? await Task.sleep(for: .milliseconds(50))
try? await Task.sleep(for: .milliseconds(200))
}
}
// Then refresh online status for dialogs that already have names
// Then refresh online status for dialogs that already have names (300ms stagger)
for key in hasName {
guard ProtocolManager.shared.connectionState == .authenticated else { break }
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
count += 1
if count > 1 {
try? await Task.sleep(for: .milliseconds(100))
try? await Task.sleep(for: .milliseconds(300))
}
}
Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(hasName.count) online status = \(count) total")
@@ -832,13 +887,13 @@ final class SessionManager {
)
for expired in result.expired {
if MessageRepository.shared.isLatestMessage(expired.messageId, in: expired.dialogKey) {
DialogRepository.shared.updateDeliveryStatus(
messageId: expired.messageId,
opponentKey: expired.dialogKey,
status: .error
)
}
// Update dialog status to error downgrade guards in
// DialogRepository.updateDeliveryStatus prevent regressions.
DialogRepository.shared.updateDeliveryStatus(
messageId: expired.messageId,
opponentKey: expired.dialogKey,
status: .error
)
resolveOutgoingRetry(messageId: expired.messageId)
}
@@ -863,8 +918,17 @@ final class SessionManager {
registerOutgoingRetry(for: packet)
} catch {
Self.logger.error("Failed to retry waiting message \(message.id): \(error.localizedDescription)")
// Mark message as error so it doesn't stay stuck at .waiting forever.
MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .error)
let opponentKey = message.toPublicKey
DialogRepository.shared.updateDeliveryStatus(
messageId: message.id,
opponentKey: opponentKey,
status: .error
)
}
}
}
private func flushPendingReadReceipts() {
@@ -916,13 +980,29 @@ final class SessionManager {
guard let packet = self.pendingOutgoingPackets[messageId] else { return }
let attempts = self.pendingOutgoingAttempts[messageId] ?? 0
// Check if message exceeded delivery timeout (80s) mark as error.
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let ageMs = nowMs - packet.timestamp
if ageMs >= self.maxOutgoingWaitingLifetimeMs {
Self.logger.warning("Message \(messageId) expired after \(ageMs)ms — marking as error")
self.markOutgoingAsError(messageId: messageId, packet: packet)
return
}
guard attempts < self.maxOutgoingRetryAttempts else {
self.resolveOutgoingRetry(messageId: messageId)
// Max retries exhausted for this connection session mark as error.
// The user sees the error icon immediately instead of a stuck clock.
Self.logger.warning("Message \(messageId) exhausted \(attempts) retries — marking as error")
self.markOutgoingAsError(messageId: messageId, packet: packet)
return
}
guard ProtocolManager.shared.connectionState == .authenticated else {
self.scheduleOutgoingRetry(messageId: messageId)
// Not authenticated don't endlessly loop. The message will be
// retried via retryWaitingOutgoingMessagesAfterReconnect() on next handshake.
Self.logger.debug("Message \(messageId) retry deferred — not authenticated")
self.resolveOutgoingRetry(messageId: messageId)
return
}
@@ -934,6 +1014,19 @@ final class SessionManager {
}
}
/// Mark an outgoing message as error in both repositories and clean up retry state.
private func markOutgoingAsError(messageId: String, packet: PacketMessage) {
let fromMe = packet.fromPublicKey == currentPublicKey
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
DialogRepository.shared.updateDeliveryStatus(
messageId: messageId,
opponentKey: opponentKey,
status: .error
)
resolveOutgoingRetry(messageId: messageId)
}
private func resolveOutgoingRetry(messageId: String) {
pendingOutgoingRetryTasks[messageId]?.cancel()
pendingOutgoingRetryTasks.removeValue(forKey: messageId)

View File

@@ -8,7 +8,7 @@ enum RosettaTab: CaseIterable, Sendable {
case calls
case settings
static let interactionOrder: [RosettaTab] = [.chats, .calls, .settings]
static let interactionOrder: [RosettaTab] = [.calls, .chats, .settings]
var label: String {
switch self {
@@ -189,10 +189,11 @@ struct RosettaTabBar: View {
.offset(x: xOffset)
} else {
// iOS < 26 thin frosted glass
// +2 centers the narrowed (width-4) pill within the tab
Capsule().fill(.thinMaterial)
.frame(width: width - 4)
.padding(.vertical, 4)
.offset(x: xOffset)
.offset(x: xOffset + 2)
}
}
.animation(

View File

@@ -1,7 +1,12 @@
import SwiftUI
import UIKit
/// Re-enables the iOS interactive swipe-back gesture when
/// `.navigationBarBackButtonHidden(true)` is used in SwiftUI.
/// Enables full-screen interactive swipe-back gesture (not just the edge)
/// when `.navigationBarBackButtonHidden(true)` is used in SwiftUI.
///
/// How it works: finds the UINavigationController's `interactivePopGestureRecognizer`,
/// extracts its internal target/action, and adds a new full-width UIPanGestureRecognizer
/// with the same target/action to the navigation controller's view.
struct SwipeBackGestureEnabler: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
SwipeBackController()
@@ -9,19 +14,51 @@ struct SwipeBackGestureEnabler: UIViewControllerRepresentable {
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
private final class SwipeBackController: UIViewController {
private final class SwipeBackController: UIViewController, UIGestureRecognizerDelegate {
private var addedGesture = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let nav = navigationController {
nav.interactivePopGestureRecognizer?.isEnabled = true
nav.interactivePopGestureRecognizer?.delegate = nil
}
guard !addedGesture else { return }
addedGesture = true
guard let nav = navigationController,
let edgeGesture = nav.interactivePopGestureRecognizer,
let targets = edgeGesture.value(forKey: "targets") as? NSArray,
targets.count > 0
else { return }
// Re-enable system gesture (in case it was disabled)
edgeGesture.isEnabled = true
// Create a full-width pan gesture with the same internal target
let fullWidthGesture = UIPanGestureRecognizer()
fullWidthGesture.setValue(targets, forKey: "targets")
fullWidthGesture.delegate = self
nav.view.addGestureRecognizer(fullWidthGesture)
}
// MARK: - UIGestureRecognizerDelegate
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
let velocity = pan.velocity(in: pan.view)
// Only allow right-swipe (positive X) and primarily horizontal
return velocity.x > 0 && abs(velocity.x) > abs(velocity.y)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
false
}
}
}
extension View {
/// Restores the swipe-back gesture after hiding the default back button.
/// Restores the swipe-back gesture from anywhere on screen
/// after hiding the default back button.
func enableSwipeBack() -> some View {
background(SwipeBackGestureEnabler())
}

View File

@@ -24,7 +24,10 @@ struct AuthCoordinator: View {
@State private var fadeOverlay: Bool = false
private var canSwipeBack: Bool {
currentScreen != .welcome
if currentScreen == .welcome {
return onBackToUnlock != nil
}
return true
}
var body: some View {
@@ -203,9 +206,15 @@ private extension AuthCoordinator {
swipeOffset = screenWidth
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
swipeOffset = 0
navigationDirection = .backward
currentScreen = backDestination
if currentScreen == .welcome {
// Don't reset swipeOffset keep screen offscreen
// while parent performs its own transition to unlock.
onBackToUnlock?()
} else {
swipeOffset = 0
navigationDirection = .backward
currentScreen = backDestination
}
}
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {

View File

@@ -1,4 +1,5 @@
import SwiftUI
import UIKit
struct SetPasswordView: View {
let seedPhrase: [String]
@@ -8,11 +9,9 @@ struct SetPasswordView: View {
@State private var password = ""
@State private var confirmPassword = ""
@State private var showPassword = false
@State private var showConfirmPassword = false
@State private var isCreating = false
@State private var errorMessage: String?
@FocusState private var focusedField: Field?
@State private var focusedField: Field?
fileprivate enum Field {
case password, confirm
@@ -115,93 +114,39 @@ private extension SetPasswordView {
private extension SetPasswordView {
var passwordField: some View {
secureInputField(
placeholder: "Password",
text: $password,
isSecure: !showPassword,
toggleAction: { showPassword.toggle() },
isRevealed: showPassword,
field: .password
)
.accessibilityLabel("Password input")
secureInputField(placeholder: "Password", text: $password, field: .password)
.accessibilityLabel("Password input")
}
var confirmField: some View {
secureInputField(
placeholder: "Confirm Password",
text: $confirmPassword,
isSecure: !showConfirmPassword,
toggleAction: { showConfirmPassword.toggle() },
isRevealed: showConfirmPassword,
field: .confirm
)
.accessibilityLabel("Confirm password input")
secureInputField(placeholder: "Confirm Password", text: $confirmPassword, field: .confirm)
.accessibilityLabel("Confirm password input")
}
func secureInputField(
placeholder: String,
text: Binding<String>,
isSecure: Bool,
toggleAction: @escaping () -> Void,
isRevealed: Bool,
field: Field
) -> some View {
HStack(spacing: 12) {
ZStack(alignment: .leading) {
// Placeholder (shown when text is empty)
if text.wrappedValue.isEmpty {
Text(placeholder)
.font(.system(size: 16))
.foregroundStyle(RosettaColors.tertiaryText)
}
// Actual input always the same type trick: use overlay to keep focus
if isSecure {
SecureField("", text: text)
.font(.system(size: 16))
.foregroundStyle(.white)
.focused($focusedField, equals: field)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} else {
TextField("", text: text)
.font(.system(size: 16))
.foregroundStyle(.white)
.focused($focusedField, equals: field)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
}
.frame(height: 22)
Image(systemName: isRevealed ? "eye.slash" : "eye")
.font(.system(size: 16))
.foregroundStyle(Color.white.opacity(0.5))
.frame(width: 30, height: 30)
.contentShape(Rectangle())
.onTapGesture {
// Save and restore focus to prevent drop
let currentFocus = focusedField
toggleAction()
DispatchQueue.main.async {
focusedField = currentFocus
}
}
.accessibilityLabel(isRevealed ? "Hide password" : "Show password")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
SecureToggleField(
text: text,
placeholder: placeholder,
field: field,
focusedField: $focusedField
)
.frame(height: 50)
.background {
let isFocused = focusedField == field
RoundedRectangle(cornerRadius: 12)
.fill(RosettaColors.cardFill)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(
isFocused ? RosettaColors.primaryBlue : RosettaColors.subtleBorder,
lineWidth: isFocused ? 2 : 1
focusedField == field
? RosettaColors.primaryBlue
: RosettaColors.subtleBorder,
lineWidth: focusedField == field ? 2 : 1
)
}
.animation(.easeInOut(duration: 0.2), value: isFocused)
}
}
}
@@ -276,6 +221,163 @@ private extension SetPasswordView {
}
}
// MARK: - Fixed Height Text Field
/// UITextField subclass with locked intrinsicContentSize and custom rect overrides.
/// Handles internal padding (16pt left, 46pt right for eye button) and rightView positioning.
/// Prevents any layout propagation to SwiftUI when isSecureTextEntry toggles.
private final class FixedHeightTextField: UITextField {
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: 50)
}
override func textRect(forBounds bounds: CGRect) -> CGRect {
bounds.inset(by: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 46))
}
override func editingRect(forBounds bounds: CGRect) -> CGRect {
textRect(forBounds: bounds)
}
override func rightViewRect(forBounds bounds: CGRect) -> CGRect {
CGRect(x: bounds.width - 16 - 30, y: (bounds.height - 30) / 2, width: 30, height: 30)
}
override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
textRect(forBounds: bounds)
}
}
// MARK: - Secure Toggle Field (UIKit)
/// Wraps UIKit's UITextField with a built-in eye toggle button as rightView.
/// The toggle happens entirely in UIKit no SwiftUI @State changes, no body re-evaluation,
/// no ScrollView layout pass. This eliminates the "input expanding" bug.
private struct SecureToggleField: UIViewRepresentable {
@Binding var text: String
var placeholder: String
var field: SetPasswordView.Field
@Binding var focusedField: SetPasswordView.Field?
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIView(context: Context) -> FixedHeightTextField {
let tf = FixedHeightTextField()
context.coordinator.textField = tf
tf.isSecureTextEntry = true
tf.font = .systemFont(ofSize: 16)
tf.textColor = .white
tf.tintColor = UIColor(RosettaColors.primaryBlue)
tf.autocapitalizationType = .none
tf.autocorrectionType = .no
tf.spellCheckingType = .no
tf.textContentType = .init(rawValue: "")
tf.inputAccessoryView = UIView(frame: .zero)
tf.backgroundColor = .clear
tf.setContentHuggingPriority(.required, for: .vertical)
tf.setContentCompressionResistancePriority(.required, for: .vertical)
tf.delegate = context.coordinator
tf.addTarget(
context.coordinator,
action: #selector(Coordinator.textChanged(_:)),
for: .editingChanged
)
tf.attributedPlaceholder = NSAttributedString(
string: placeholder,
attributes: [.foregroundColor: UIColor(RosettaColors.tertiaryText)]
)
// Eye toggle button entirely UIKit, no SwiftUI state involved
let config = UIImage.SymbolConfiguration(pointSize: 16)
let eyeButton = UIButton(type: .system)
eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal)
eyeButton.tintColor = UIColor.white.withAlphaComponent(0.5)
eyeButton.addTarget(
context.coordinator,
action: #selector(Coordinator.toggleSecure),
for: .touchUpInside
)
eyeButton.frame = CGRect(x: 0, y: 0, width: 30, height: 30)
eyeButton.accessibilityLabel = "Show password"
tf.rightView = eyeButton
tf.rightViewMode = .always
return tf
}
func updateUIView(_ tf: FixedHeightTextField, context: Context) {
context.coordinator.parent = self
// Sync text (only if different to avoid cursor jump)
if tf.text != text { tf.text = text }
// Sync focus (deferred to avoid re-entrant layout)
let wantsFocus = focusedField == field
if wantsFocus && !tf.isFirstResponder {
DispatchQueue.main.async { tf.becomeFirstResponder() }
} else if !wantsFocus && tf.isFirstResponder {
DispatchQueue.main.async { tf.resignFirstResponder() }
}
}
/// Lock the size SwiftUI sees prevents intrinsicContentSize changes
/// from propagating as layout invalidations.
func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: FixedHeightTextField,
context: Context
) -> CGSize? {
CGSize(width: proposal.width ?? 200, height: 50)
}
final class Coordinator: NSObject, UITextFieldDelegate {
var parent: SecureToggleField
weak var textField: FixedHeightTextField?
init(parent: SecureToggleField) { self.parent = parent }
@objc func textChanged(_ tf: UITextField) {
parent.text = tf.text ?? ""
}
/// Toggle isSecureTextEntry entirely in UIKit no SwiftUI @State change.
@objc func toggleSecure() {
guard let tf = textField else { return }
UIView.performWithoutAnimation {
let existingText = tf.text
tf.isSecureTextEntry.toggle()
// iOS clears text when toggling isSecureTextEntry restore it
tf.text = ""
tf.text = existingText
// Update eye icon
let imageName = tf.isSecureTextEntry ? "eye" : "eye.slash"
let config = UIImage.SymbolConfiguration(pointSize: 16)
let button = tf.rightView as? UIButton
button?.setImage(
UIImage(systemName: imageName, withConfiguration: config),
for: .normal
)
button?.accessibilityLabel = tf.isSecureTextEntry
? "Show password"
: "Hide password"
}
}
func textFieldDidBeginEditing(_ textField: UITextField) {
parent.focusedField = parent.field
}
func textFieldDidEndEditing(_ textField: UITextField) {
if parent.focusedField == parent.field {
parent.focusedField = nil
}
}
}
}
#Preview {
SetPasswordView(
seedPhrase: ["abandon", "ability", "able", "about", "above", "absent",

View File

@@ -5,11 +5,18 @@ struct ChatDetailView: View {
var onPresentedChange: ((Bool) -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@ObservedObject private var messageRepository = MessageRepository.shared
@StateObject private var viewModel: ChatDetailViewModel
init(route: ChatRoute, onPresentedChange: ((Bool) -> Void)? = nil) {
self.route = route
self.onPresentedChange = onPresentedChange
_viewModel = StateObject(wrappedValue: ChatDetailViewModel(dialogKey: route.publicKey))
}
@State private var messageText = ""
@State private var sendError: String?
@State private var isViewActive = false
// markReadTask removed read receipts no longer sent from .onChange(of: messages.count)
@FocusState private var isInputFocused: Bool
private var currentPublicKey: String {
@@ -21,11 +28,11 @@ struct ChatDetailView: View {
}
private var messages: [ChatMessage] {
messageRepository.messages(for: route.publicKey)
viewModel.messages
}
private var isTyping: Bool {
messageRepository.isTyping(dialogKey: route.publicKey)
viewModel.isTyping
}
private var titleText: String {
@@ -45,7 +52,6 @@ struct ChatDetailView: View {
private var subtitleText: String {
if route.isSavedMessages { return "" }
if ProtocolManager.shared.connectionState != .authenticated { return "connecting..." }
if isTyping { return "typing..." }
if let dialog, dialog.isOnline { return "online" }
return "offline"
@@ -74,9 +80,7 @@ struct ChatDetailView: View {
private var sendButtonWidth: CGFloat { 38 }
private var sendButtonHeight: CGFloat { 36 }
private var composerTrailingPadding: CGFloat {
isInputFocused ? 16 : 28
}
private var composerTrailingPadding: CGFloat { 16 }
private var composerAnimation: Animation {
.spring(response: 0.28, dampingFraction: 0.9)
@@ -125,7 +129,7 @@ struct ChatDetailView: View {
}
.onDisappear {
isViewActive = false
messageRepository.setDialogActive(route.publicKey, isActive: false)
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
}
}
}
@@ -319,20 +323,25 @@ private extension ChatDetailView {
}
}
/// Cached tiled pattern color computed once, reused across renders
private static let cachedTiledColor: Color? = {
guard let uiImage = UIImage(named: "ChatBackground"),
let cgImage = uiImage.cgImage else { return nil }
let tileWidth: CGFloat = 200
let scaleFactor = uiImage.size.width / tileWidth
let scaledImage = UIImage(
cgImage: cgImage,
scale: uiImage.scale * scaleFactor,
orientation: .up
)
return Color(uiColor: UIColor(patternImage: scaledImage))
}()
/// 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
)
Color(uiColor: UIColor(patternImage: scaledImage))
.opacity(0.18)
if let color = Self.cachedTiledColor {
color.opacity(0.18)
} else {
Color.clear
}
@@ -389,7 +398,8 @@ private extension ChatDetailView {
ScrollViewReader { proxy in
let scroll = ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
ForEach(messages.indices, id: \.self) { index in
let message = messages[index]
messageRow(
message,
maxBubbleWidth: maxBubbleWidth,
@@ -406,7 +416,7 @@ private extension ChatDetailView {
.padding(.top, messagesTopInset)
.padding(.bottom, 10)
}
.scrollDismissesKeyboard(.interactively)
.scrollDismissesKeyboard(.immediately)
.onTapGesture { isInputFocused = false }
.onAppear {
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
@@ -420,16 +430,19 @@ private extension ChatDetailView {
}
.onChange(of: messages.count) { _, _ in
scrollToBottom(proxy: proxy, animated: true)
if isViewActive {
markDialogAsRead()
}
// Read receipts are NOT sent here SessionManager already sends
// 0x07 for each incoming message when dialog is active (shouldMarkRead).
// Sending again from .onChange caused duplicate packets (2-3× more than
// desktop), which may contribute to server RST disconnects.
// The initial read is handled in .task with 600ms delay.
}
.onChange(of: isInputFocused) { _, focused in
guard focused else { return }
// User tapped the input reset idle timer.
SessionManager.shared.recordUserInteraction()
// Delay matches keyboard animation (~250ms) so scroll happens after layout settles.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 80_000_000)
try? await Task.sleep(nanoseconds: 300_000_000)
scrollToBottom(proxy: proxy, animated: true)
}
}
@@ -469,7 +482,13 @@ private extension ChatDetailView {
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
)
if outgoing { deliveryIndicator(message.deliveryStatus) }
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
deliveryIndicator(message.deliveryStatus)
}
}
}
.padding(.trailing, 11)
.padding(.bottom, 5)
@@ -545,50 +564,11 @@ private extension ChatDetailView {
.frame(height: 36, alignment: .center)
.overlay(alignment: .trailing) {
Button(action: sendCurrentMessage) {
ZStack {
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.blendMode(.difference)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.blendMode(.saturation)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.blendMode(.overlay)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .black
)
.blendMode(.overlay)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.blendMode(.overlay)
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .black
)
.blendMode(.overlay)
}
.compositingGroup()
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.opacity(0.42 + (0.58 * sendButtonProgress))
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
.frame(width: 22, height: 19)
@@ -644,10 +624,9 @@ private extension ChatDetailView {
.padding(.leading, 16)
.padding(.trailing, composerTrailingPadding)
.padding(.top, 4)
.padding(.bottom, isInputFocused ? 8 : 0)
.padding(.bottom, 4)
.animation(composerAnimation, value: canSend)
.animation(composerAnimation, value: shouldShowSendButton)
.animation(composerAnimation, value: isInputFocused)
}
.background {
if #available(iOS 26, *) {
@@ -796,6 +775,41 @@ private extension ChatDetailView {
}
}
@ViewBuilder
func errorMenu(for message: ChatMessage) -> some View {
Menu {
Button {
retryMessage(message)
} label: {
Label("Retry", systemImage: "arrow.clockwise")
}
Button(role: .destructive) {
removeMessage(message)
} label: {
Label("Remove", systemImage: "trash")
}
} label: {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(RosettaColors.error)
}
}
func retryMessage(_ message: ChatMessage) {
let text = message.text
let toKey = message.toPublicKey
MessageRepository.shared.deleteMessage(id: message.id)
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: toKey)
Task {
try? await SessionManager.shared.sendMessage(text: text, toPublicKey: toKey)
}
}
func removeMessage(_ message: ChatMessage) {
MessageRepository.shared.deleteMessage(id: message.id)
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey)
}
func messageTime(_ timestamp: Int64) -> String {
Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000))
}
@@ -829,12 +843,12 @@ private extension ChatDetailView {
myPublicKey: currentPublicKey
)
}
messageRepository.setDialogActive(route.publicKey, isActive: true)
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
}
func markDialogAsRead() {
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
messageRepository.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
}

View File

@@ -0,0 +1,65 @@
import Foundation
import Combine
/// Per-dialog observation isolation for ChatDetailView.
///
/// Instead of `@ObservedObject messageRepository` (which re-renders on ANY dialog change),
/// this ViewModel subscribes only to the specific dialog's messages via Combine pipeline
/// with `removeDuplicates()`. The view re-renders ONLY when its own dialog's data changes.
@MainActor
final class ChatDetailViewModel: ObservableObject {
let dialogKey: String
@Published private(set) var messages: [ChatMessage] = []
@Published private(set) var isTyping: Bool = false
private var cancellables = Set<AnyCancellable>()
init(dialogKey: String) {
self.dialogKey = dialogKey
let repo = MessageRepository.shared
// Seed with current values
messages = repo.messages(for: dialogKey)
isTyping = repo.isTyping(dialogKey: dialogKey)
// Subscribe to messagesByDialog changes, filtered to our dialog only.
// Broken into steps to help the Swift type-checker.
let key = dialogKey
let messagesPublisher = repo.$messagesByDialog
.map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in
dict[key] ?? []
}
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
guard lhs.count == rhs.count else { return false }
for i in lhs.indices {
if lhs[i].id != rhs[i].id || lhs[i].deliveryStatus != rhs[i].deliveryStatus {
return false
}
}
return true
}
.receive(on: DispatchQueue.main)
messagesPublisher
.sink { [weak self] newMessages in
self?.messages = newMessages
}
.store(in: &cancellables)
// Subscribe to typing state changes, filtered to our dialog
let typingPublisher = repo.$typingDialogs
.map { (dialogs: Set<String>) -> Bool in
dialogs.contains(key)
}
.removeDuplicates()
.receive(on: DispatchQueue.main)
typingPublisher
.sink { [weak self] typing in
self?.isTyping = typing
}
.store(in: &cancellables)
}
}

View File

@@ -140,6 +140,11 @@ private extension ChatListSearchContent {
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in
recentRow(recent)
if recent.publicKey != viewModel.recentSearches.last?.publicKey {
Divider()
.padding(.leading, 68)
.foregroundStyle(RosettaColors.Adaptive.divider)
}
}
}
@@ -179,7 +184,7 @@ private extension ChatListSearchContent {
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button {
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
onOpenDialog(ChatRoute(recent: user))
} label: {
HStack(spacing: 10) {
AvatarView(
@@ -207,6 +212,7 @@ private extension ChatListSearchContent {
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@@ -263,6 +269,7 @@ private extension ChatListSearchContent {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}

View File

@@ -32,16 +32,13 @@ struct ChatListView: View {
@State private var hasPinnedChats = false
@FocusState private var isSearchFocused: Bool
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🟡 ChatListView.body #\(Self._bodyCount)")
NavigationStack(path: $navigationState.path) {
VStack(spacing: 0) {
// Custom search bar
customSearchBar
.padding(.horizontal, 16)
.padding(.top, 6)
.padding(.top, 12)
.padding(.bottom, 8)
.background(
(hasPinnedChats && !isSearchActive
@@ -290,7 +287,7 @@ private extension ChatListView {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(height: 44)
.frame(height: 40)
.padding(.horizontal, 10)
}
.buttonStyle(.plain)
@@ -312,7 +309,7 @@ private extension ChatListView {
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.frame(width: 44, height: 44)
.frame(width: 40, height: 40)
}
.buttonStyle(.plain)
.accessibilityLabel("Add chat")
@@ -323,7 +320,7 @@ private extension ChatListView {
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.frame(width: 44, height: 44)
.frame(width: 40, height: 40)
}
.buttonStyle(.plain)
.accessibilityLabel("New chat")
@@ -377,10 +374,7 @@ private struct ToolbarTitleView: View {
/// Changes to these `@Observable` singletons only re-render this small view,
/// not the parent ChatListView / NavigationStack.
private struct ToolbarStoriesAvatar: View {
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🟣 ToolbarStoriesAvatar.body #\(Self._bodyCount)")
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk
@@ -396,10 +390,7 @@ private struct ToolbarStoriesAvatar: View {
/// During handshake, `connectionState` changes 4+ times rapidly this view
/// absorbs those re-renders instead of cascading them to the NavigationStack.
private struct DeviceVerificationBannersContainer: View {
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("⚪ DeviceVerificationBanners.body #\(Self._bodyCount)")
let proto = ProtocolManager.shared
if proto.connectionState == .deviceVerificationRequired {
@@ -424,11 +415,7 @@ private struct ChatListDialogContent: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
var onPinnedStateChange: (Bool) -> Void = { _ in }
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🔶 ChatListDialogContent.body #\(Self._bodyCount)")
let hasPinned = !viewModel.pinnedDialogs.isEmpty
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")

View File

@@ -46,8 +46,8 @@ final class ChatListViewModel: ObservableObject {
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
var totalUnreadCount: Int {
DialogRepository.shared.sortedDialogs
.filter { !$0.isMuted }
DialogRepository.shared.dialogs.values
.lazy.filter { !$0.isMuted }
.reduce(0) { $0 + $1.unreadCount }
}
@@ -172,7 +172,7 @@ final class ChatListViewModel: ObservableObject {
var packet = PacketSearch()
packet.privateKey = hash
packet.search = query
packet.search = query.lowercased()
Self.logger.debug("📤 Sending search packet for '\(query)'")
ProtocolManager.shared.sendPacket(packet)
}

View File

@@ -1,4 +1,5 @@
import SwiftUI
import Combine
// MARK: - ChatRowView
@@ -20,6 +21,11 @@ import SwiftUI
struct ChatRowView: View {
let dialog: Dialog
/// Desktop parity: recheck delivery timeout every 40s so clock error
/// transitions happen automatically without user scrolling.
@State private var now = Date()
private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect()
var displayTitle: String {
if dialog.isSavedMessages { return "Saved Messages" }
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
@@ -38,6 +44,7 @@ struct ChatRowView: View {
.padding(.trailing, 16)
.frame(height: 78)
.contentShape(Rectangle())
.onReceive(recheckTimer) { now = $0 }
}
}
@@ -215,7 +222,7 @@ private extension ChatRowView {
private var isWithinWaitingWindow: Bool {
guard dialog.lastMessageTimestamp > 0 else { return true }
let sentDate = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
return Date().timeIntervalSince(sentDate) < Self.maxWaitingSeconds
return now.timeIntervalSince(sentDate) < Self.maxWaitingSeconds
}
var unreadBadge: some View {
@@ -244,6 +251,16 @@ private extension ChatRowView {
// MARK: - Time Formatting
private extension ChatRowView {
private static let timeFormatter: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "h:mm a"; return f
}()
private static let dayFormatter: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE"; return f
}()
private static let dateFormatter: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f
}()
var formattedTime: String {
guard dialog.lastMessageTimestamp > 0 else { return "" }
@@ -252,19 +269,13 @@ private extension ChatRowView {
let calendar = Calendar.current
if calendar.isDateInToday(date) {
let f = DateFormatter()
f.dateFormat = "h:mm a"
return f.string(from: date)
return Self.timeFormatter.string(from: date)
} else if calendar.isDateInYesterday(date) {
return "Yesterday"
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 {
let f = DateFormatter()
f.dateFormat = "EEE"
return f.string(from: date)
return Self.dayFormatter.string(from: date)
} else {
let f = DateFormatter()
f.dateFormat = "dd.MM.yy"
return f.string(from: date)
return Self.dateFormatter.string(from: date)
}
}
}

View File

@@ -108,6 +108,7 @@ private extension SearchResultsSection {
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}

View File

@@ -280,6 +280,7 @@ private struct RecentSection: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}

View File

@@ -88,7 +88,7 @@ final class SearchViewModel: ObservableObject {
var packet = PacketSearch()
packet.privateKey = hash
packet.search = currentQuery
packet.search = currentQuery.lowercased()
ProtocolManager.shared.sendPacket(packet)
}

View File

@@ -29,6 +29,12 @@ struct MainTabView: View {
@available(iOS 26.0, *)
private var systemTabView: some View {
TabView(selection: $selectedTab) {
CallsView()
.tabItem {
Label(RosettaTab.calls.label, systemImage: RosettaTab.calls.icon)
}
.tag(RosettaTab.calls)
ChatListView(
isSearchActive: $isChatSearchActive,
isDetailPresented: $isChatListDetailPresented
@@ -39,12 +45,6 @@ struct MainTabView: View {
.tag(RosettaTab.chats)
.badge(chatUnreadCount)
CallsView()
.tabItem {
Label(RosettaTab.calls.label, systemImage: RosettaTab.calls.icon)
}
.tag(RosettaTab.calls)
SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented)
.tabItem {
Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon)
@@ -72,7 +72,7 @@ struct MainTabView: View {
onTabSelected: { tab in
activatedTabs.insert(tab)
for t in RosettaTab.interactionOrder { activatedTabs.insert(t) }
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
withAnimation(.easeInOut(duration: 0.15)) {
selectedTab = tab
}
},
@@ -83,7 +83,7 @@ struct MainTabView: View {
}
dragFractionalIndex = state.fractionalIndex
} else {
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
withAnimation(.easeInOut(duration: 0.15)) {
dragFractionalIndex = nil
}
}
@@ -107,23 +107,30 @@ struct MainTabView: View {
@ViewBuilder
private func tabPager(availableSize: CGSize) -> some View {
let width = max(1, availableSize.width)
let totalWidth = width * CGFloat(RosettaTab.interactionOrder.count)
HStack(spacing: 0) {
ZStack {
ForEach(RosettaTab.interactionOrder, id: \.self) { tab in
tabView(for: tab)
.frame(width: width, height: availableSize.height)
.opacity(tabOpacity(for: tab))
.allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil)
}
}
.frame(width: totalWidth, alignment: .leading)
.modifier(PagerOffsetModifier(
effectiveIndex: dragFractionalIndex ?? currentPageIndex,
pageWidth: width,
isDragging: dragFractionalIndex != nil
))
.clipped()
}
private func tabOpacity(for tab: RosettaTab) -> Double {
if let frac = dragFractionalIndex {
// During drag: crossfade between adjacent tabs
let tabIndex = CGFloat(tab.interactionIndex)
let distance = abs(frac - tabIndex)
if distance >= 1 { return 0 }
return Double(1 - distance)
} else {
return tab == selectedTab ? 1 : 0
}
}
@ViewBuilder
private func tabView(for tab: RosettaTab) -> some View {
if activatedTabs.contains(tab) {

View File

@@ -61,6 +61,8 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
}
}
deinit {}
// MARK: DataSource
func pageViewController(

View File

@@ -8,7 +8,7 @@ struct ProfileEditView: View {
@Binding var displayName: String
@Binding var username: String
let publicKey: String
var onLogout: () -> Void
var onLogout: () -> Void = {}
@State private var selectedPhotoItem: PhotosPickerItem?
@State private var selectedPhoto: UIImage?
@@ -160,7 +160,7 @@ private extension ProfileEditView {
Button(action: onLogout) {
HStack {
Spacer()
Text("Log Out")
Text("Delete Account")
.font(.system(size: 17))
.foregroundStyle(RosettaColors.error)
Spacer()
@@ -187,8 +187,7 @@ private extension ProfileEditView {
ProfileEditView(
displayName: .constant("Gaidar"),
username: .constant("GaidarTheDev"),
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec",
onLogout: {}
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec"
)
}
.background(RosettaColors.Adaptive.background)

View File

@@ -9,7 +9,7 @@ struct SettingsView: View {
@StateObject private var viewModel = SettingsViewModel()
@State private var showCopiedToast = false
@State private var showLogoutConfirmation = false
@State private var showDeleteAccountConfirmation = false
// Edit mode field state initialized when entering edit mode
@State private var editDisplayName = ""
@@ -23,7 +23,7 @@ struct SettingsView: View {
displayName: $editDisplayName,
username: $editUsername,
publicKey: viewModel.publicKey,
onLogout: { showLogoutConfirmation = true }
onLogout: { showDeleteAccountConfirmation = true }
)
.transition(.opacity)
} else {
@@ -37,14 +37,15 @@ struct SettingsView: View {
.toolbar { toolbarContent }
.toolbarBackground(.hidden, for: .navigationBar)
.task { viewModel.refresh() }
.alert("Log Out", isPresented: $showLogoutConfirmation) {
.alert("Delete Account", isPresented: $showDeleteAccountConfirmation) {
Button("Cancel", role: .cancel) {}
Button("Log Out", role: .destructive) {
Button("Delete Account", role: .destructive) {
SessionManager.shared.endSession()
try? AccountManager.shared.deleteAccount()
onLogout?()
}
} message: {
Text("Are you sure you want to log out?")
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.")
}
.onChange(of: isEditingProfile) { _, isEditing in
if !isEditing { viewModel.refresh() }
@@ -166,7 +167,6 @@ struct SettingsView: View {
profileHeader
accountSection
settingsSection
dangerSection
}
.padding(.horizontal, 16)
.padding(.top, 8)
@@ -256,11 +256,11 @@ struct SettingsView: View {
private var dangerSection: some View {
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
Button {
showLogoutConfirmation = true
showDeleteAccountConfirmation = true
} label: {
HStack {
Spacer()
Text("Log Out")
Text("Delete Account")
.font(.system(size: 17))
.foregroundStyle(RosettaColors.error)
Spacer()

View File

@@ -181,7 +181,8 @@ struct RosettaApp: App {
case .main:
MainTabView(onLogout: {
isLoggedIn = false
fadeTransition(to: .unlock)
hasCompletedOnboarding = false
fadeTransition(to: .onboarding)
})
}
}