Исправление расширения поля пароля при переключении видимости: перенос toggle в UIKit
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
65
Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift
Normal file
65
Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ private extension SearchResultsSection {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 5)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ private struct RecentSection: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 5)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -61,6 +61,8 @@ struct OnboardingPager<Page: View>: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
deinit {}
|
||||
|
||||
// MARK: DataSource
|
||||
|
||||
func pageViewController(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -181,7 +181,8 @@ struct RosettaApp: App {
|
||||
case .main:
|
||||
MainTabView(onLogout: {
|
||||
isLoggedIn = false
|
||||
fadeTransition(to: .unlock)
|
||||
hasCompletedOnboarding = false
|
||||
fadeTransition(to: .onboarding)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user