Доставка сообщений при потере сети, кэш фото при отправке, FPS клавиатуры, свайп фото, badge tab bar, release notes, sync unread fix
This commit is contained in:
@@ -417,7 +417,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -433,7 +433,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.9;
|
MARKETING_VERSION = 1.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -472,7 +472,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.9;
|
MARKETING_VERSION = 1.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -53,14 +53,14 @@
|
|||||||
<CommandLineArguments>
|
<CommandLineArguments>
|
||||||
<CommandLineArgument
|
<CommandLineArgument
|
||||||
argument = "-LogForEachSlowPath YES"
|
argument = "-LogForEachSlowPath YES"
|
||||||
isEnabled = "YES">
|
isEnabled = "NO">
|
||||||
</CommandLineArgument>
|
</CommandLineArgument>
|
||||||
</CommandLineArguments>
|
</CommandLineArguments>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "CA_DEBUG_TRANSACTIONS"
|
key = "CA_DEBUG_TRANSACTIONS"
|
||||||
value = "1"
|
value = "1"
|
||||||
isEnabled = "YES">
|
isEnabled = "NO">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ final class MessageRepository: ObservableObject {
|
|||||||
if fromMe, messages[existingIndex].deliveryStatus == .error {
|
if fromMe, messages[existingIndex].deliveryStatus == .error {
|
||||||
messages[existingIndex].deliveryStatus = fromSync ? .delivered : .waiting
|
messages[existingIndex].deliveryStatus = fromSync ? .delivered : .waiting
|
||||||
}
|
}
|
||||||
if incomingRead {
|
// Mark as read if dialog is active OR if this is a sync-restored
|
||||||
|
// known message (was read on another device).
|
||||||
|
if incomingRead || (fromSync && wasKnownBefore && !fromMe) {
|
||||||
messages[existingIndex].isRead = true
|
messages[existingIndex].isRead = true
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -404,6 +406,23 @@ final class MessageRepository: ObservableObject {
|
|||||||
schedulePersist()
|
schedulePersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Android parity: persist immediately (no debounce) after sending a message.
|
||||||
|
/// Ensures the outgoing message with .waiting status survives app termination.
|
||||||
|
/// Android saves to Room DB synchronously in sendMessage(); iOS debounces at 800ms.
|
||||||
|
func persistNow() {
|
||||||
|
guard !currentAccount.isEmpty else { return }
|
||||||
|
persistTask?.cancel()
|
||||||
|
let snapshot = messagesByDialog
|
||||||
|
let idsSnapshot = allKnownMessageIds
|
||||||
|
let messagesFile = Self.messagesFileName(for: currentAccount)
|
||||||
|
let knownIdsFile = Self.knownIdsFileName(for: currentAccount)
|
||||||
|
let password = storagePassword.isEmpty ? nil : storagePassword
|
||||||
|
persistTask = Task(priority: .userInitiated) {
|
||||||
|
await ChatPersistenceStore.shared.save(snapshot, fileName: messagesFile, password: password)
|
||||||
|
await ChatPersistenceStore.shared.save(idsSnapshot, fileName: knownIdsFile, password: password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func normalizeTimestamp(_ raw: Int64) -> Int64 {
|
private func normalizeTimestamp(_ raw: Int64) -> Int64 {
|
||||||
// Some peers still send seconds, while Android path uses milliseconds.
|
// Some peers still send seconds, while Android path uses milliseconds.
|
||||||
raw < 1_000_000_000_000 ? raw * 1000 : raw
|
raw < 1_000_000_000_000 ? raw * 1000 : raw
|
||||||
|
|||||||
@@ -104,41 +104,23 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify connection health after returning from background.
|
/// Android parity: `reconnectNowIfNeeded()` — if already in an active state,
|
||||||
/// Fast path: if already authenticated, send a WebSocket ping first (< 100ms).
|
/// skip reconnect. Otherwise reset backoff and connect immediately.
|
||||||
/// If pong arrives, connection is alive — no reconnect needed.
|
|
||||||
/// If ping fails or times out (500ms), force full reconnect.
|
|
||||||
func reconnectIfNeeded() {
|
func reconnectIfNeeded() {
|
||||||
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
|
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
|
||||||
|
|
||||||
// Don't interrupt active handshake
|
// Android parity: skip if already in any active state.
|
||||||
if connectionState == .handshaking { return }
|
switch connectionState {
|
||||||
|
case .authenticated, .handshaking, .deviceVerificationRequired, .connected:
|
||||||
// Fast path: if authenticated, try ping first before tearing down.
|
|
||||||
if connectionState == .authenticated, client.isConnected {
|
|
||||||
Self.logger.info("Foreground — ping check")
|
|
||||||
client.sendPing { [weak self] error in
|
|
||||||
guard let self else { return }
|
|
||||||
if error == nil {
|
|
||||||
// Pong received — connection alive, send heartbeat to keep it fresh.
|
|
||||||
Self.logger.info("Foreground ping OK — connection alive")
|
|
||||||
self.client.sendText("heartbeat")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Ping failed — connection dead, force reconnect.
|
|
||||||
Self.logger.info("Foreground ping failed — force reconnecting")
|
|
||||||
self.handshakeComplete = false
|
|
||||||
self.heartbeatTask?.cancel()
|
|
||||||
Task { @MainActor in
|
|
||||||
self.connectionState = .connecting
|
|
||||||
}
|
|
||||||
self.client.forceReconnect()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
|
case .connecting:
|
||||||
|
if client.isConnected { return }
|
||||||
|
case .disconnected:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not authenticated — force reconnect immediately.
|
// Reset backoff and connect immediately.
|
||||||
Self.logger.info("Foreground reconnect — force reconnecting")
|
Self.logger.info("⚡ Fast reconnect — state=\(self.connectionState.rawValue)")
|
||||||
handshakeComplete = false
|
handshakeComplete = false
|
||||||
heartbeatTask?.cancel()
|
heartbeatTask?.cancel()
|
||||||
connectionState = .connecting
|
connectionState = .connecting
|
||||||
@@ -402,6 +384,8 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
switch packet.handshakeState {
|
switch packet.handshakeState {
|
||||||
case .completed:
|
case .completed:
|
||||||
handshakeComplete = true
|
handshakeComplete = true
|
||||||
|
// Android parity: reset backoff counter on successful authentication.
|
||||||
|
client.resetReconnectAttempts()
|
||||||
Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s")
|
Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s")
|
||||||
|
|
||||||
flushPacketQueue()
|
flushPacketQueue()
|
||||||
@@ -441,16 +425,39 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
// Android parity: heartbeat at 1/3 the server-specified interval (more aggressive keep-alive).
|
// Android parity: heartbeat at 1/3 the server-specified interval (more aggressive keep-alive).
|
||||||
let intervalNs = UInt64(interval) * 1_000_000_000 / 3
|
let intervalNs = UInt64(interval) * 1_000_000_000 / 3
|
||||||
|
|
||||||
heartbeatTask = Task {
|
heartbeatTask = Task { [weak self] in
|
||||||
// Send first heartbeat immediately
|
// Send first heartbeat immediately
|
||||||
client.sendText("heartbeat")
|
self?.sendHeartbeat()
|
||||||
|
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(nanoseconds: intervalNs)
|
try? await Task.sleep(nanoseconds: intervalNs)
|
||||||
guard !Task.isCancelled else { break }
|
guard !Task.isCancelled else { break }
|
||||||
|
self?.sendHeartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android parity: send heartbeat and trigger disconnect on failure.
|
||||||
|
private func sendHeartbeat() {
|
||||||
|
let state = connectionState
|
||||||
|
guard state == .authenticated || state == .deviceVerificationRequired else { return }
|
||||||
|
guard client.isConnected else {
|
||||||
|
Self.logger.warning("💔 Heartbeat failed: socket not connected — triggering reconnect")
|
||||||
|
handleHeartbeatFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
client.sendText("heartbeat")
|
client.sendText("heartbeat")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Android parity: failed heartbeat → handleDisconnect.
|
||||||
|
private func handleHeartbeatFailure() {
|
||||||
|
heartbeatTask?.cancel()
|
||||||
|
handshakeComplete = false
|
||||||
|
Task { @MainActor in
|
||||||
|
self.connectionState = .disconnected
|
||||||
}
|
}
|
||||||
|
// Let WebSocketClient's own handleDisconnect schedule reconnect.
|
||||||
|
client.forceReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Packet Queue
|
// MARK: - Packet Queue
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
private var hasNotifiedConnected = false
|
private var hasNotifiedConnected = false
|
||||||
private(set) var isConnected = false
|
private(set) var isConnected = false
|
||||||
private var disconnectHandledForCurrentSocket = false
|
private var disconnectHandledForCurrentSocket = false
|
||||||
/// Android parity: exponential backoff counter, reset on successful connection.
|
/// Android parity: exponential backoff counter, reset on AUTHENTICATED (not on open).
|
||||||
private var reconnectAttempts = 0
|
private var reconnectAttempts = 0
|
||||||
|
|
||||||
/// NWPathMonitor for instant reconnect on network changes (Wi-Fi ↔ cellular, etc.).
|
/// NWPathMonitor for instant reconnect on network changes (Wi-Fi ↔ cellular, etc.).
|
||||||
@@ -85,7 +85,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Immediately reconnect, bypassing scheduled retry.
|
/// Immediately reconnect, bypassing scheduled retry.
|
||||||
/// Used when returning from background to establish connection ASAP.
|
/// Android parity: `reconnectNowIfNeeded()` — reset backoff and connect.
|
||||||
func forceReconnect() {
|
func forceReconnect() {
|
||||||
guard !isManuallyClosed else { return }
|
guard !isManuallyClosed else { return }
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
@@ -101,6 +101,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Android parity: reset backoff counter on successful AUTHENTICATED state.
|
||||||
|
func resetReconnectAttempts() {
|
||||||
|
reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func send(_ data: Data, onFailure: ((Error?) -> Void)? = nil) -> Bool {
|
func send(_ data: Data, onFailure: ((Error?) -> Void)? = nil) -> Bool {
|
||||||
guard isConnected, let task = webSocketTask else {
|
guard isConnected, let task = webSocketTask else {
|
||||||
@@ -151,7 +156,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
disconnectHandledForCurrentSocket = false
|
disconnectHandledForCurrentSocket = false
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
reconnectTask = nil
|
reconnectTask = nil
|
||||||
reconnectAttempts = 0
|
// Android parity: backoff reset moved to AUTHENTICATED (ProtocolManager).
|
||||||
|
// Do NOT reset here — handshake may still fail.
|
||||||
onConnected?()
|
onConnected?()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,20 +208,16 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
guard !isManuallyClosed else { return }
|
guard !isManuallyClosed else { return }
|
||||||
|
|
||||||
guard reconnectTask == nil else { return }
|
guard reconnectTask == nil else { return }
|
||||||
// First attempt: reconnect immediately (0ms delay) for fastest recovery.
|
// Android parity: exponential backoff — 1s, 2s, 4s, 8s, 16s, 30s (cap).
|
||||||
// Subsequent attempts: exponential backoff — 1s, 2s, 4s, 8s, 16s (cap).
|
// No instant first attempt. Formula: min(1000 * 2^(n-1), 30000).
|
||||||
reconnectAttempts += 1
|
reconnectAttempts += 1
|
||||||
if reconnectAttempts == 1 {
|
|
||||||
// Immediate retry — no delay on first attempt.
|
if reconnectAttempts > 20 {
|
||||||
Self.logger.info("Reconnecting immediately (attempt #1)...")
|
Self.logger.warning("⚠️ Too many reconnect attempts (\(self.reconnectAttempts)), may be stuck in loop")
|
||||||
reconnectTask = Task { [weak self] in
|
|
||||||
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
|
||||||
self.reconnectTask = nil
|
|
||||||
self.connect()
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
let exponent = min(reconnectAttempts - 2, 4)
|
let exponent = min(reconnectAttempts - 1, 4)
|
||||||
let delayMs = min(1000 * (1 << exponent), 16000)
|
let delayMs = min(1000 * (1 << exponent), 30000)
|
||||||
reconnectTask = Task { [weak self] in
|
reconnectTask = Task { [weak self] in
|
||||||
Self.logger.info("Reconnecting in \(delayMs)ms (attempt #\(self?.reconnectAttempts ?? 0))...")
|
Self.logger.info("Reconnecting in \(delayMs)ms (attempt #\(self?.reconnectAttempts ?? 0))...")
|
||||||
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||||
@@ -225,4 +227,3 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ final class SessionManager {
|
|||||||
// MARK: - Foreground Detection (Android parity)
|
// MARK: - Foreground Detection (Android parity)
|
||||||
|
|
||||||
private var foregroundObserverToken: NSObjectProtocol?
|
private var foregroundObserverToken: NSObjectProtocol?
|
||||||
|
/// Android parity: 5s debounce between foreground sync requests.
|
||||||
|
private var lastForegroundSyncTime: TimeInterval = 0
|
||||||
|
|
||||||
/// Whether the app is in the foreground.
|
/// Whether the app is in the foreground.
|
||||||
private var isAppInForeground: Bool {
|
private var isAppInForeground: Bool {
|
||||||
@@ -184,6 +186,12 @@ final class SessionManager {
|
|||||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android parity: persist IMMEDIATELY after inserting outgoing message.
|
||||||
|
// Without this, if app is killed within 800ms debounce window,
|
||||||
|
// the message is lost forever (only in memory, not on disk).
|
||||||
|
// Android saves to Room DB synchronously in sendMessage().
|
||||||
|
MessageRepository.shared.persistNow()
|
||||||
|
|
||||||
// Saved Messages: local-only, no server send
|
// Saved Messages: local-only, no server send
|
||||||
if toPublicKey == currentPublicKey {
|
if toPublicKey == currentPublicKey {
|
||||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||||
@@ -319,6 +327,7 @@ final class SessionManager {
|
|||||||
|
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
registerOutgoingRetry(for: packet)
|
registerOutgoingRetry(for: packet)
|
||||||
|
MessageRepository.shared.persistNow()
|
||||||
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)")
|
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,10 +357,11 @@ final class SessionManager {
|
|||||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
// Encrypt message text (use single space if empty — desktop parity)
|
// Android parity: no caption → encrypt empty string "".
|
||||||
let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? " " : text
|
// Receivers decrypt "" → show "Photo"/"File" in chat list.
|
||||||
|
let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let encrypted = try MessageCrypto.encryptOutgoing(
|
let encrypted = try MessageCrypto.encryptOutgoing(
|
||||||
plaintext: messageText,
|
plaintext: messageText.isEmpty ? "" : messageText,
|
||||||
recipientPublicKeyHex: toPublicKey
|
recipientPublicKeyHex: toPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -420,6 +430,15 @@ final class SessionManager {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android parity: cache original images BEFORE upload so they display
|
||||||
|
// instantly in the chat bubble. Without this, photo doesn't appear until
|
||||||
|
// upload completes (can take seconds on slow connection).
|
||||||
|
for item in encryptedAttachments {
|
||||||
|
if item.original.type == .image, let image = UIImage(data: item.original.data) {
|
||||||
|
AttachmentCache.shared.saveImage(image, forAttachmentId: item.original.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2: Upload all attachments concurrently (Android parity: backgroundUploadScope).
|
// Phase 2: Upload all attachments concurrently (Android parity: backgroundUploadScope).
|
||||||
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
||||||
of: (Int, String).self
|
of: (Int, String).self
|
||||||
@@ -515,8 +534,8 @@ final class SessionManager {
|
|||||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
||||||
let offlineAsSend = !isConnected
|
let offlineAsSend = !isConnected
|
||||||
|
|
||||||
// For outgoing messages, store attachment password so we can view our own sent images
|
// messageText is already trimmed — "" for no caption, triggers "Photo"/"File" in updateFromMessage.
|
||||||
let displayText = messageText == " " ? " " : messageText
|
let displayText = messageText
|
||||||
DialogRepository.shared.updateFromMessage(
|
DialogRepository.shared.updateFromMessage(
|
||||||
packet, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
|
packet, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
|
||||||
)
|
)
|
||||||
@@ -546,6 +565,7 @@ final class SessionManager {
|
|||||||
|
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
registerOutgoingRetry(for: packet)
|
registerOutgoingRetry(for: packet)
|
||||||
|
MessageRepository.shared.persistNow()
|
||||||
Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…")
|
Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,6 +736,7 @@ final class SessionManager {
|
|||||||
|
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
registerOutgoingRetry(for: packet)
|
registerOutgoingRetry(for: packet)
|
||||||
|
MessageRepository.shared.persistNow()
|
||||||
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)")
|
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1753,28 +1774,29 @@ final class SessionManager {
|
|||||||
let attachCount = max(1, Int64(packet.attachments.count))
|
let attachCount = max(1, Int64(packet.attachments.count))
|
||||||
let timeoutMs = self.maxOutgoingWaitingLifetimeMs * attachCount
|
let timeoutMs = self.maxOutgoingWaitingLifetimeMs * attachCount
|
||||||
if ageMs >= timeoutMs {
|
if ageMs >= timeoutMs {
|
||||||
// Server didn't send 0x08, but we were authenticated and packets
|
// Android parity: mark as ERROR after timeout, not DELIVERED.
|
||||||
// were sent successfully. Most likely cause: server deduplicates
|
// If the message was actually delivered, server will send 0x08 on reconnect
|
||||||
// by messageId (original was delivered before disconnect, ACK lost).
|
// (or sync will restore the message). Marking DELIVERED optimistically
|
||||||
// Mark as DELIVERED (optimistic) rather than ERROR.
|
// hides the problem from the user — they think it was sent but it wasn't.
|
||||||
Self.logger.info("Message \(messageId) — no ACK after \(ageMs)ms, marking as delivered (optimistic)")
|
Self.logger.warning("Message \(messageId) — no ACK after \(ageMs)ms, marking as ERROR")
|
||||||
self.markOutgoingAsDelivered(messageId: messageId, packet: packet)
|
self.markOutgoingAsError(messageId: messageId, packet: packet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard attempts < self.maxOutgoingRetryAttempts else {
|
guard attempts < self.maxOutgoingRetryAttempts else {
|
||||||
// Max retries exhausted while connected — same reasoning:
|
// Android parity: mark as ERROR after max retries.
|
||||||
// packets were sent, no error from server, likely delivered.
|
// User can manually retry via error menu.
|
||||||
Self.logger.info("Message \(messageId) — no ACK after \(attempts) retries, marking as delivered (optimistic)")
|
Self.logger.warning("Message \(messageId) — no ACK after \(attempts) retries, marking as ERROR")
|
||||||
self.markOutgoingAsDelivered(messageId: messageId, packet: packet)
|
self.markOutgoingAsError(messageId: messageId, packet: packet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard ProtocolManager.shared.connectionState == .authenticated else {
|
guard ProtocolManager.shared.connectionState == .authenticated else {
|
||||||
// Not authenticated — don't endlessly loop. The message will be
|
// Android parity: don't resolve (cancel) retry — keep message as .waiting.
|
||||||
// retried via retryWaitingOutgoingMessagesAfterReconnect() on next handshake.
|
// retryWaitingOutgoingMessagesAfterReconnect() will pick it up on next handshake.
|
||||||
Self.logger.debug("Message \(messageId) retry deferred — not authenticated")
|
// Previously: resolveOutgoingRetry() cancelled everything → message stuck as
|
||||||
self.resolveOutgoingRetry(messageId: messageId)
|
// .delivered (optimistic) → never retried → lost.
|
||||||
|
Self.logger.debug("Message \(messageId) retry deferred — not authenticated, keeping .waiting")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1894,7 +1916,10 @@ final class SessionManager {
|
|||||||
// Android parity: version + text hash — re-sends if text changed within same version.
|
// Android parity: version + text hash — re-sends if text changed within same version.
|
||||||
let noticeText = ReleaseNotes.releaseNoticeText
|
let noticeText = ReleaseNotes.releaseNoticeText
|
||||||
guard !noticeText.isEmpty else { return }
|
guard !noticeText.isEmpty else { return }
|
||||||
let currentKey = "\(ReleaseNotes.appVersion)_\(noticeText.hashValue)"
|
// Use stable hash — Swift's hashValue is randomized per launch (ASLR seed).
|
||||||
|
// Android uses Java's hashCode() which is deterministic.
|
||||||
|
let stableHash = noticeText.utf8.reduce(0) { ($0 &* 31) &+ Int($1) }
|
||||||
|
let currentKey = "\(ReleaseNotes.appVersion)_\(stableHash)"
|
||||||
|
|
||||||
guard lastKey != currentKey else { return }
|
guard lastKey != currentKey else { return }
|
||||||
|
|
||||||
@@ -1908,20 +1933,24 @@ final class SessionManager {
|
|||||||
packet.timestamp = now
|
packet.timestamp = now
|
||||||
packet.messageId = messageId
|
packet.messageId = messageId
|
||||||
|
|
||||||
// Insert message into MessageRepository (delivered immediately).
|
// Insert message into MessageRepository (delivered, already read).
|
||||||
MessageRepository.shared.upsertFromMessagePacket(
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
packet,
|
packet,
|
||||||
myPublicKey: publicKey,
|
myPublicKey: publicKey,
|
||||||
decryptedText: noticeText
|
decryptedText: noticeText,
|
||||||
|
fromSync: true // fromSync = true → message marked as delivered + read
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create/update dialog in DialogRepository.
|
// Create/update dialog in DialogRepository.
|
||||||
|
// isNewMessage: false — don't increment unread count for release notes.
|
||||||
DialogRepository.shared.updateFromMessage(
|
DialogRepository.shared.updateFromMessage(
|
||||||
packet,
|
packet,
|
||||||
myPublicKey: publicKey,
|
myPublicKey: publicKey,
|
||||||
decryptedText: noticeText,
|
decryptedText: noticeText,
|
||||||
isNewMessage: true
|
isNewMessage: false
|
||||||
)
|
)
|
||||||
|
// Force-clear unread for system Updates account — release notes are always "read".
|
||||||
|
DialogRepository.shared.markAsRead(opponentKey: Account.updatesPublicKey)
|
||||||
|
|
||||||
// Set system account display info (title, username, verified badge).
|
// Set system account display info (title, username, verified badge).
|
||||||
// Desktop parity: both system accounts use verified=1 (blue rosette badge).
|
// Desktop parity: both system accounts use verified=1 (blue rosette badge).
|
||||||
@@ -1939,7 +1968,7 @@ final class SessionManager {
|
|||||||
// MARK: - Foreground Observer (Android parity)
|
// MARK: - Foreground Observer (Android parity)
|
||||||
|
|
||||||
private func setupForegroundObserver() {
|
private func setupForegroundObserver() {
|
||||||
// Android parity: ON_RESUME → markVisibleMessagesAsRead() + reconnect.
|
// Android parity: ON_RESUME → markVisibleMessagesAsRead() + reconnect + sync.
|
||||||
foregroundObserverToken = NotificationCenter.default.addObserver(
|
foregroundObserverToken = NotificationCenter.default.addObserver(
|
||||||
forName: UIApplication.willEnterForegroundNotification,
|
forName: UIApplication.willEnterForegroundNotification,
|
||||||
object: nil,
|
object: nil,
|
||||||
@@ -1948,9 +1977,24 @@ final class SessionManager {
|
|||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
|
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
|
||||||
self?.markActiveDialogsAsRead()
|
self?.markActiveDialogsAsRead()
|
||||||
// Always verify connection on foreground — don't trust cached state.
|
// Android parity: reconnectNowIfNeeded() — only reconnects if disconnected.
|
||||||
ProtocolManager.shared.reconnectIfNeeded()
|
ProtocolManager.shared.reconnectIfNeeded()
|
||||||
|
// Android parity: syncOnForeground() — sync if already authenticated.
|
||||||
|
self?.syncOnForeground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Android parity: `syncOnForeground()` — request sync on foreground resume
|
||||||
|
/// if already authenticated and 5s have passed since last foreground sync.
|
||||||
|
private func syncOnForeground() {
|
||||||
|
guard ProtocolManager.shared.connectionState == .authenticated else { return }
|
||||||
|
guard !syncBatchInProgress else { return }
|
||||||
|
guard !syncRequestInFlight else { return }
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
guard now - lastForegroundSyncTime >= 5 else { return }
|
||||||
|
lastForegroundSyncTime = now
|
||||||
|
Self.logger.info("🔄 Sync on foreground resume")
|
||||||
|
requestSynchronize()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,20 @@ enum ReleaseNotes {
|
|||||||
Entry(
|
Entry(
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
body: """
|
body: """
|
||||||
|
**Доставка сообщений**
|
||||||
|
Сообщения больше не теряются при потере сети. Автоматический повтор отправки при восстановлении соединения. Отправленные фото отображаются мгновенно.
|
||||||
|
|
||||||
**Уведомления**
|
**Уведомления**
|
||||||
Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне.
|
Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне.
|
||||||
|
|
||||||
**Фото и файлы**
|
**Фото и файлы**
|
||||||
Фото скачиваются только по тапу — блюр-превью со стрелкой загрузки. Пересланные фото подтягиваются из кэша автоматически. Файлы открываются по тапу — скачивание и «Поделиться». Меню вложений (скрепка) работает на iOS 26+.
|
Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
|
||||||
|
|
||||||
**Оптимизация производительности**
|
**Производительность**
|
||||||
Улучшен FPS скролла и клавиатуры в длинных переписках.
|
Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
|
||||||
|
|
||||||
**Исправления**
|
**Исправления**
|
||||||
Убрана рамка у сообщений с аватаром. Saved Messages: иконка закладки вместо аватара. Read receipts: паритет с Android.
|
Исправлены нерабочие кнопки на iOS 26+. Увеличен отступ строки ввода. Исправлен счётчик непрочитанных после синхронизации. Saved Messages: иконка закладки вместо аватара.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,17 +19,13 @@ struct GlassBackButton: View {
|
|||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var glassCircle: some View {
|
private var glassCircle: some View {
|
||||||
if #available(iOS 26, *) {
|
// Use TelegramGlassCircle for all versions — it supports iOS 26 natively
|
||||||
Circle()
|
// (UIGlassEffect) and has isUserInteractionEnabled = false, guaranteeing
|
||||||
.fill(Color.white.opacity(0.08))
|
// touches pass through. SwiftUI .glassEffect() intercepts taps.
|
||||||
.glassEffect(.regular, in: .circle)
|
|
||||||
} else {
|
|
||||||
TelegramGlassCircle()
|
TelegramGlassCircle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Primary Button (Liquid Glass)
|
// MARK: - Primary Button (Liquid Glass)
|
||||||
|
|
||||||
@@ -41,28 +37,16 @@ struct RosettaPrimaryButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
Group {
|
|
||||||
if #available(iOS 26, *) {
|
|
||||||
configuration.label
|
configuration.label
|
||||||
.background {
|
.background {
|
||||||
Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.7 : 1.0))
|
Capsule()
|
||||||
|
.fill(fillColor.opacity(configuration.isPressed ? 0.7 : 1.0))
|
||||||
}
|
}
|
||||||
.glassEffect(.regular, in: Capsule())
|
|
||||||
} else {
|
|
||||||
configuration.label
|
|
||||||
.background { glassBackground(isPressed: configuration.isPressed) }
|
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
|
||||||
}
|
|
||||||
.scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0)
|
.scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0)
|
||||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||||
.allowsHitTesting(isEnabled)
|
.allowsHitTesting(isEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func glassBackground(isPressed: Bool) -> some View {
|
|
||||||
Capsule()
|
|
||||||
.fill(fillColor.opacity(isPressed ? 0.7 : 1.0))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Settings Row (Telegram-like instant press highlight)
|
// MARK: - Settings Row (Telegram-like instant press highlight)
|
||||||
@@ -85,9 +69,10 @@ private struct SettingsHighlightModifier: ViewModifier {
|
|||||||
.background(shape.fill(isPressed ? Color.white.opacity(0.08) : Color.clear))
|
.background(shape.fill(isPressed ? Color.white.opacity(0.08) : Color.clear))
|
||||||
.clipShape(shape)
|
.clipShape(shape)
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
DragGesture(minimumDistance: 0)
|
DragGesture(minimumDistance: 5)
|
||||||
.updating($isPressed) { value, state, _ in
|
.updating($isPressed) { value, state, _ in
|
||||||
state = abs(value.translation.height) < 10
|
state = abs(value.translation.height) < 10
|
||||||
|
&& abs(value.translation.width) < 10
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,14 +36,9 @@ struct GlassCard<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 26, *) {
|
|
||||||
content()
|
|
||||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius))
|
|
||||||
} else {
|
|
||||||
content()
|
content()
|
||||||
.background {
|
.background {
|
||||||
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,29 +2,22 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - Glass Modifier
|
// MARK: - Glass Modifier
|
||||||
//
|
//
|
||||||
// iOS 26+: native .glassEffect API
|
// Uses TelegramGlass* UIViewRepresentable for ALL iOS versions.
|
||||||
// iOS < 26: Telegram-style glass (CABackdropLayer + gaussianBlur)
|
// TelegramGlassUIView supports iOS 26 natively (UIGlassEffect) and has
|
||||||
|
// isUserInteractionEnabled = false, guaranteeing touches pass through.
|
||||||
|
// SwiftUI .glassEffect() modifier creates UIKit containers that intercept
|
||||||
|
// taps even with .allowsHitTesting(false).
|
||||||
|
|
||||||
struct GlassModifier: ViewModifier {
|
struct GlassModifier: ViewModifier {
|
||||||
let cornerRadius: CGFloat
|
let cornerRadius: CGFloat
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
||||||
|
|
||||||
if #available(iOS 26, *) {
|
|
||||||
content
|
|
||||||
.background {
|
|
||||||
shape.fill(.clear)
|
|
||||||
.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content
|
content
|
||||||
.background {
|
.background {
|
||||||
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - View Extension
|
// MARK: - View Extension
|
||||||
|
|
||||||
@@ -35,32 +28,16 @@ extension View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Glass capsule — convenience for pill-shaped elements.
|
/// Glass capsule — convenience for pill-shaped elements.
|
||||||
@ViewBuilder
|
|
||||||
func glassCapsule() -> some View {
|
func glassCapsule() -> some View {
|
||||||
if #available(iOS 26, *) {
|
|
||||||
background {
|
|
||||||
Capsule().fill(.clear)
|
|
||||||
.glassEffect(.regular, in: .capsule)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
background {
|
background {
|
||||||
TelegramGlassCapsule()
|
TelegramGlassCapsule()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Glass circle — convenience for circular buttons.
|
/// Glass circle — convenience for circular buttons.
|
||||||
@ViewBuilder
|
|
||||||
func glassCircle() -> some View {
|
func glassCircle() -> some View {
|
||||||
if #available(iOS 26, *) {
|
|
||||||
background {
|
|
||||||
Circle().fill(.clear)
|
|
||||||
.glassEffect(.regular, in: .circle)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
background {
|
background {
|
||||||
TelegramGlassCircle()
|
TelegramGlassCircle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -133,20 +133,9 @@ struct RosettaTabBar: View {
|
|||||||
selectionIndicator
|
selectionIndicator
|
||||||
}
|
}
|
||||||
.background {
|
.background {
|
||||||
if #available(iOS 26.0, *) {
|
// TelegramGlassCapsule handles both iOS 26+ (UIGlassEffect)
|
||||||
// iOS 26+ — native liquid glass
|
// and iOS < 26 (CABackdropLayer), with isUserInteractionEnabled = false.
|
||||||
Capsule()
|
TelegramGlassCapsule()
|
||||||
.fill(.clear)
|
|
||||||
.glassEffect(.regular, in: .capsule)
|
|
||||||
} else {
|
|
||||||
// iOS < 26 — frosted glass material (Telegram-style)
|
|
||||||
Capsule()
|
|
||||||
.fill(.regularMaterial)
|
|
||||||
.overlay(
|
|
||||||
Capsule()
|
|
||||||
.strokeBorder(TabBarColors.pillBorder, lineWidth: 0.5)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
.gesture(dragGesture)
|
.gesture(dragGesture)
|
||||||
@@ -156,6 +145,13 @@ struct RosettaTabBar: View {
|
|||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.onAppear { Task { @MainActor in refreshBadges() } }
|
.onAppear { Task { @MainActor in refreshBadges() } }
|
||||||
.onChange(of: selectedTab) { _, _ in Task { @MainActor in refreshBadges() } }
|
.onChange(of: selectedTab) { _, _ in Task { @MainActor in refreshBadges() } }
|
||||||
|
// Observation-isolated badge refresh: checks dialogsVersion on every
|
||||||
|
// DialogRepository mutation. Only calls refreshBadges() when version changes.
|
||||||
|
.overlay {
|
||||||
|
BadgeVersionObserver(onVersionChanged: refreshBadges)
|
||||||
|
.frame(width: 0, height: 0)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads DialogRepository outside the body's observation scope.
|
/// Reads DialogRepository outside the body's observation scope.
|
||||||
@@ -185,6 +181,7 @@ struct RosettaTabBar: View {
|
|||||||
// iOS 26+ — native liquid glass
|
// iOS 26+ — native liquid glass
|
||||||
Capsule().fill(.clear)
|
Capsule().fill(.clear)
|
||||||
.glassEffect(.regular, in: .capsule)
|
.glassEffect(.regular, in: .capsule)
|
||||||
|
.allowsHitTesting(false)
|
||||||
.frame(width: width)
|
.frame(width: width)
|
||||||
.offset(x: xOffset)
|
.offset(x: xOffset)
|
||||||
} else {
|
} else {
|
||||||
@@ -353,6 +350,40 @@ private extension Comparable {
|
|||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
|
// MARK: - Badge Version Observer (observation-isolated)
|
||||||
|
|
||||||
|
/// Observes DialogRepository in its own scope, debounced badge refresh.
|
||||||
|
/// Body re-evaluates on any dialog mutation (observation tracking via `dialogs` read).
|
||||||
|
/// Debounces at 500ms to avoid CPU spikes from rapid mutations.
|
||||||
|
private struct BadgeVersionObserver: View {
|
||||||
|
var onVersionChanged: () -> Void
|
||||||
|
@State private var refreshTask: Task<Void, Never>?
|
||||||
|
@State private var lastUnread: Int = -1
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// O(n) reduce — but only on THIS view's body (isolated scope).
|
||||||
|
// Not called during RosettaTabBar drag/animation.
|
||||||
|
let unread = DialogRepository.shared.dialogs.values
|
||||||
|
.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) }
|
||||||
|
Color.clear
|
||||||
|
.onChange(of: unread) { _, newValue in
|
||||||
|
guard newValue != lastUnread else { return }
|
||||||
|
lastUnread = newValue
|
||||||
|
// Debounce to avoid rapid refreshes during sync
|
||||||
|
refreshTask?.cancel()
|
||||||
|
refreshTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(200))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
onVersionChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
lastUnread = unread
|
||||||
|
onVersionChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ final class TelegramGlassUIView: UIView {
|
|||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
clipsToBounds = false
|
clipsToBounds = false
|
||||||
|
// CRITICAL: disable user interaction so glass background never intercepts
|
||||||
|
// touches from SwiftUI Buttons that use this view as .background.
|
||||||
|
isUserInteractionEnabled = false
|
||||||
|
|
||||||
if #available(iOS 26.0, *) {
|
if #available(iOS 26.0, *) {
|
||||||
setupNativeGlass()
|
setupNativeGlass()
|
||||||
@@ -129,6 +132,7 @@ final class TelegramGlassUIView: UIView {
|
|||||||
effect.tintColor = UIColor(white: 1.0, alpha: 0.025)
|
effect.tintColor = UIColor(white: 1.0, alpha: 0.025)
|
||||||
let glassView = UIVisualEffectView(effect: effect)
|
let glassView = UIVisualEffectView(effect: effect)
|
||||||
glassView.layer.cornerCurve = .continuous
|
glassView.layer.cornerCurve = .continuous
|
||||||
|
glassView.isUserInteractionEnabled = false
|
||||||
addSubview(glassView)
|
addSubview(glassView)
|
||||||
nativeGlassView = glassView
|
nativeGlassView = glassView
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,16 +187,9 @@ struct AttachmentPanelView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 22–34).
|
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 22–34).
|
||||||
@ViewBuilder
|
|
||||||
private var closeButtonGlass: some View {
|
private var closeButtonGlass: some View {
|
||||||
if #available(iOS 26, *) {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.white.opacity(0.08))
|
|
||||||
.glassEffect(.regular, in: .circle)
|
|
||||||
} else {
|
|
||||||
TelegramGlassCircle()
|
TelegramGlassCircle()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Title text changes based on selected tab.
|
/// Title text changes based on selected tab.
|
||||||
private var tabTitle: String {
|
private var tabTitle: String {
|
||||||
@@ -318,16 +311,9 @@ struct AttachmentPanelView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Glass background matching ChatDetailView's composer (`.thinMaterial` + stroke + shadow).
|
/// Glass background matching ChatDetailView's composer (`.thinMaterial` + stroke + shadow).
|
||||||
@ViewBuilder
|
|
||||||
private var captionBarBackground: some View {
|
private var captionBarBackground: some View {
|
||||||
if #available(iOS 26, *) {
|
|
||||||
RoundedRectangle(cornerRadius: 21, style: .continuous)
|
|
||||||
.fill(.clear)
|
|
||||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
|
|
||||||
} else {
|
|
||||||
TelegramGlassRoundedRect(cornerRadius: 21)
|
TelegramGlassRoundedRect(cornerRadius: 21)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tab Bar (Figma: glass capsule, 3 tabs)
|
// MARK: - Tab Bar (Figma: glass capsule, 3 tabs)
|
||||||
|
|
||||||
@@ -349,22 +335,8 @@ struct AttachmentPanelView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Glass background matching RosettaTabBar (lines 136–149).
|
/// Glass background matching RosettaTabBar (lines 136–149).
|
||||||
@ViewBuilder
|
|
||||||
private var tabBarBackground: some View {
|
private var tabBarBackground: some View {
|
||||||
if #available(iOS 26, *) {
|
TelegramGlassCapsule()
|
||||||
// iOS 26+ — native liquid glass
|
|
||||||
Capsule()
|
|
||||||
.fill(.clear)
|
|
||||||
.glassEffect(.regular, in: .capsule)
|
|
||||||
} else {
|
|
||||||
// iOS < 26 — matches RosettaTabBar: .regularMaterial + border
|
|
||||||
Capsule()
|
|
||||||
.fill(.regularMaterial)
|
|
||||||
.overlay(
|
|
||||||
Capsule()
|
|
||||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Individual tab button matching RosettaTabBar dimensions exactly.
|
/// Individual tab button matching RosettaTabBar dimensions exactly.
|
||||||
@@ -402,16 +374,7 @@ struct AttachmentPanelView: View {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background {
|
.background {
|
||||||
if isSelected {
|
if isSelected {
|
||||||
if #available(iOS 26, *) {
|
TelegramGlassCapsule()
|
||||||
Capsule()
|
|
||||||
.fill(.clear)
|
|
||||||
.glassEffect(.regular, in: .capsule)
|
|
||||||
} else {
|
|
||||||
// Matches RosettaTabBar selection indicator: .thinMaterial
|
|
||||||
Capsule()
|
|
||||||
.fill(.thinMaterial)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -804,6 +804,7 @@ private extension ChatDetailView {
|
|||||||
)
|
)
|
||||||
.frame(width: 14, height: 8)
|
.frame(width: 14, height: 8)
|
||||||
.frame(width: 42, height: 42)
|
.frame(width: 42, height: 42)
|
||||||
|
.contentShape(Circle())
|
||||||
.background {
|
.background {
|
||||||
glass(shape: .circle, strokeOpacity: 0.18)
|
glass(shape: .circle, strokeOpacity: 0.18)
|
||||||
}
|
}
|
||||||
@@ -1456,6 +1457,7 @@ private extension ChatDetailView {
|
|||||||
)
|
)
|
||||||
.frame(width: 21, height: 24)
|
.frame(width: 21, height: 24)
|
||||||
.frame(width: 42, height: 42)
|
.frame(width: 42, height: 42)
|
||||||
|
.contentShape(Circle())
|
||||||
.background { glass(shape: .circle, strokeOpacity: 0.18) }
|
.background { glass(shape: .circle, strokeOpacity: 0.18) }
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Attach")
|
.accessibilityLabel("Attach")
|
||||||
@@ -1558,8 +1560,8 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
.padding(.leading, 16)
|
.padding(.leading, 16)
|
||||||
.padding(.trailing, composerTrailingPadding)
|
.padding(.trailing, composerTrailingPadding)
|
||||||
.padding(.top, 4)
|
.padding(.top, 6)
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 12)
|
||||||
.simultaneousGesture(composerDismissGesture)
|
.simultaneousGesture(composerDismissGesture)
|
||||||
.animation(composerAnimation, value: canSend)
|
.animation(composerAnimation, value: canSend)
|
||||||
.animation(composerAnimation, value: shouldShowSendButton)
|
.animation(composerAnimation, value: shouldShowSendButton)
|
||||||
@@ -1588,7 +1590,8 @@ private extension ChatDetailView {
|
|||||||
|
|
||||||
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
|
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
|
||||||
|
|
||||||
/// Determines bubble position within a group of consecutive same-sender plain-text messages.
|
/// Determines bubble position within a group of consecutive same-sender messages.
|
||||||
|
/// Telegram parity: photo messages group with text messages from the same sender.
|
||||||
func bubblePosition(for index: Int) -> BubblePosition {
|
func bubblePosition(for index: Int) -> BubblePosition {
|
||||||
let hasPrev: Bool = {
|
let hasPrev: Bool = {
|
||||||
guard index > 0 else { return false }
|
guard index > 0 else { return false }
|
||||||
@@ -1596,7 +1599,7 @@ private extension ChatDetailView {
|
|||||||
let current = messages[index]
|
let current = messages[index]
|
||||||
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
|
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
|
||||||
== prev.isFromMe(myPublicKey: currentPublicKey)
|
== prev.isFromMe(myPublicKey: currentPublicKey)
|
||||||
return sameSender && prev.attachments.isEmpty && current.attachments.isEmpty
|
return sameSender
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let hasNext: Bool = {
|
let hasNext: Bool = {
|
||||||
@@ -1605,7 +1608,7 @@ private extension ChatDetailView {
|
|||||||
let current = messages[index]
|
let current = messages[index]
|
||||||
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
|
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
|
||||||
== next.isFromMe(myPublicKey: currentPublicKey)
|
== next.isFromMe(myPublicKey: currentPublicKey)
|
||||||
return sameSender && next.attachments.isEmpty && current.attachments.isEmpty
|
return sameSender
|
||||||
}()
|
}()
|
||||||
|
|
||||||
switch (hasPrev, hasNext) {
|
switch (hasPrev, hasNext) {
|
||||||
@@ -1637,19 +1640,11 @@ private extension ChatDetailView {
|
|||||||
strokeOpacity: Double = 0.18,
|
strokeOpacity: Double = 0.18,
|
||||||
strokeColor: Color = .white
|
strokeColor: Color = .white
|
||||||
) -> some View {
|
) -> some View {
|
||||||
if #available(iOS 26.0, *) {
|
// Use TelegramGlass* UIViewRepresentable for ALL iOS versions.
|
||||||
switch shape {
|
// TelegramGlassUIView already supports iOS 26 natively (UIGlassEffect)
|
||||||
case .capsule:
|
// and has isUserInteractionEnabled = false, guaranteeing touches pass
|
||||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
// through to parent Buttons. SwiftUI .glassEffect() modifier creates
|
||||||
case .circle:
|
// UIKit containers that intercept taps even with .allowsHitTesting(false).
|
||||||
Circle().fill(.clear).glassEffect(.regular, in: .circle)
|
|
||||||
case let .rounded(radius):
|
|
||||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
||||||
.fill(.clear)
|
|
||||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// iOS < 26: Telegram glass (CABackdropLayer + blur 2.0 + dark foreground)
|
|
||||||
switch shape {
|
switch shape {
|
||||||
case .capsule:
|
case .capsule:
|
||||||
TelegramGlassCapsule()
|
TelegramGlassCapsule()
|
||||||
@@ -1659,7 +1654,6 @@ private extension ChatDetailView {
|
|||||||
TelegramGlassRoundedRect(cornerRadius: radius)
|
TelegramGlassRoundedRect(cornerRadius: radius)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions / utils
|
// MARK: - Actions / utils
|
||||||
|
|
||||||
|
|||||||
@@ -158,15 +158,19 @@ struct ImageGalleryViewer: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var controlsOverlay: some View {
|
private var controlsOverlay: some View {
|
||||||
if showControls && !isDismissing {
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
// Android parity: slide + fade, 200ms, FastOutSlowInEasing, 24pt slide distance.
|
||||||
|
if showControls && !isDismissing {
|
||||||
topBar
|
topBar
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if showControls && !isDismissing {
|
||||||
bottomBar
|
bottomBar
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: showControls)
|
|
||||||
}
|
}
|
||||||
|
.animation(.easeOut(duration: 0.2), value: showControls)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Top Bar (Android: sender name + date, back arrow)
|
// MARK: - Top Bar (Android: sender name + date, back arrow)
|
||||||
|
|||||||
@@ -17,19 +17,86 @@ struct ZoomableImagePage: View {
|
|||||||
let onEdgeTap: ((Int) -> Void)?
|
let onEdgeTap: ((Int) -> Void)?
|
||||||
|
|
||||||
@State private var image: UIImage?
|
@State private var image: UIImage?
|
||||||
|
/// Vertical drag offset for dismiss gesture (SwiftUI DragGesture).
|
||||||
|
@State private var dismissDragOffset: CGFloat = 0
|
||||||
|
|
||||||
|
@State private var zoomScale: CGFloat = 1.0
|
||||||
|
@State private var zoomOffset: CGSize = .zero
|
||||||
|
@GestureState private var pinchScale: CGFloat = 1.0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let image {
|
if let image {
|
||||||
ZoomableImageUIViewRepresentable(
|
let effectiveScale = zoomScale * pinchScale
|
||||||
image: image,
|
|
||||||
onDismiss: onDismiss,
|
Image(uiImage: image)
|
||||||
onDismissProgress: onDismissProgress,
|
.resizable()
|
||||||
onDismissCancel: onDismissCancel,
|
.scaledToFit()
|
||||||
onToggleControls: { showControls.toggle() },
|
.scaleEffect(effectiveScale)
|
||||||
onScaleChanged: { scale in currentScale = scale },
|
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
||||||
onEdgeTap: onEdgeTap
|
y: (effectiveScale > 1.05 ? zoomOffset.height : 0) + dismissDragOffset)
|
||||||
|
// Single tap: toggle controls / edge navigation
|
||||||
|
.onTapGesture { location in
|
||||||
|
let width = UIScreen.main.bounds.width
|
||||||
|
let edgeZone = width * 0.20
|
||||||
|
if location.x < edgeZone {
|
||||||
|
onEdgeTap?(-1)
|
||||||
|
} else if location.x > width - edgeZone {
|
||||||
|
onEdgeTap?(1)
|
||||||
|
} else {
|
||||||
|
showControls.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Double tap: zoom to 2.5x or reset
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||||
|
if zoomScale > 1.1 {
|
||||||
|
zoomScale = 1.0
|
||||||
|
zoomOffset = .zero
|
||||||
|
} else {
|
||||||
|
zoomScale = 2.5
|
||||||
|
}
|
||||||
|
currentScale = zoomScale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pinch zoom
|
||||||
|
.gesture(
|
||||||
|
MagnifyGesture()
|
||||||
|
.updating($pinchScale) { value, state, _ in
|
||||||
|
state = value.magnification
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
let newScale = zoomScale * value.magnification
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||||
|
zoomScale = min(max(newScale, 1.0), 5.0)
|
||||||
|
if zoomScale <= 1.05 {
|
||||||
|
zoomScale = 1.0
|
||||||
|
zoomOffset = .zero
|
||||||
|
}
|
||||||
|
currentScale = zoomScale
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
// Pan when zoomed
|
||||||
|
.gesture(
|
||||||
|
zoomScale > 1.05 ?
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
zoomOffset = value.translation
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
// Clamp offset
|
||||||
|
}
|
||||||
|
: nil
|
||||||
|
)
|
||||||
|
// Dismiss drag (vertical swipe when not zoomed)
|
||||||
|
// simultaneousGesture so it coexists with TabView's page swipe.
|
||||||
|
// The 2.0× vertical ratio in dismissDragGesture prevents
|
||||||
|
// horizontal swipes from triggering dismiss.
|
||||||
|
.simultaneousGesture(
|
||||||
|
zoomScale <= 1.05 ? dismissDragGesture : nil
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
} else {
|
} else {
|
||||||
placeholder
|
placeholder
|
||||||
}
|
}
|
||||||
@@ -39,6 +106,35 @@ struct ZoomableImagePage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vertical drag-to-dismiss gesture.
|
||||||
|
/// Uses minimumDistance:40 to give TabView's page swipe a head start.
|
||||||
|
private var dismissDragGesture: some Gesture {
|
||||||
|
DragGesture(minimumDistance: 40, coordinateSpace: .local)
|
||||||
|
.onChanged { value in
|
||||||
|
let dy = abs(value.translation.height)
|
||||||
|
let dx = abs(value.translation.width)
|
||||||
|
// Only vertical-dominant drags trigger dismiss
|
||||||
|
guard dy > dx * 2.0 else { return }
|
||||||
|
|
||||||
|
dismissDragOffset = value.translation.height
|
||||||
|
let progress = min(abs(dismissDragOffset) / 300, 1.0)
|
||||||
|
onDismissProgress(progress)
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
let velocityY = abs(value.predictedEndTranslation.height - value.translation.height)
|
||||||
|
if abs(dismissDragOffset) > 100 || velocityY > 500 {
|
||||||
|
// Dismiss — keep offset so photo doesn't jump back before fade-out
|
||||||
|
onDismiss()
|
||||||
|
} else {
|
||||||
|
// Snap back
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) {
|
||||||
|
dismissDragOffset = 0
|
||||||
|
}
|
||||||
|
onDismissCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Placeholder
|
// MARK: - Placeholder
|
||||||
|
|
||||||
private var placeholder: some View {
|
private var placeholder: some View {
|
||||||
@@ -116,6 +212,7 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
|||||||
// MARK: - Subviews
|
// MARK: - Subviews
|
||||||
|
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
|
private var panGesture: UIPanGestureRecognizer?
|
||||||
|
|
||||||
// MARK: - Transform State
|
// MARK: - Transform State
|
||||||
|
|
||||||
@@ -184,10 +281,10 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
|||||||
pinch.delegate = self
|
pinch.delegate = self
|
||||||
addGestureRecognizer(pinch)
|
addGestureRecognizer(pinch)
|
||||||
|
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
// Pan gesture REMOVED — replaced by SwiftUI DragGesture on the wrapper view.
|
||||||
pan.delegate = self
|
// UIKit UIPanGestureRecognizer on UIViewRepresentable intercepts ALL touches
|
||||||
pan.maximumNumberOfTouches = 1
|
// before SwiftUI TabView gets them, preventing page swipe navigation.
|
||||||
addGestureRecognizer(pan)
|
// SwiftUI DragGesture cooperates with TabView natively.
|
||||||
|
|
||||||
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
|
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
|
||||||
doubleTap.numberOfTapsRequired = 2
|
doubleTap.numberOfTapsRequired = 2
|
||||||
@@ -276,62 +373,32 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Pan Gesture (Pan when zoomed, Dismiss when not)
|
// MARK: - Pan Gesture (Pan when zoomed ONLY)
|
||||||
|
// Dismiss gesture moved to SwiftUI DragGesture on ZoomableImagePage wrapper
|
||||||
|
// to allow TabView page swipe to work.
|
||||||
|
|
||||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||||
|
// Only handle pan when zoomed — dismiss is handled by SwiftUI DragGesture
|
||||||
|
guard currentScale > 1.05 else {
|
||||||
|
gesture.state = .cancelled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let translation = gesture.translation(in: self)
|
let translation = gesture.translation(in: self)
|
||||||
let velocity = gesture.velocity(in: self)
|
|
||||||
|
|
||||||
switch gesture.state {
|
switch gesture.state {
|
||||||
case .began:
|
case .began:
|
||||||
panStartOffset = currentOffset
|
panStartOffset = currentOffset
|
||||||
gestureAxisLocked = false
|
|
||||||
isDismissGesture = false
|
|
||||||
|
|
||||||
case .changed:
|
case .changed:
|
||||||
if currentScale > 1.05 {
|
|
||||||
// Zoomed: pan the image
|
|
||||||
currentOffset = CGPoint(
|
currentOffset = CGPoint(
|
||||||
x: panStartOffset.x + translation.x,
|
x: panStartOffset.x + translation.x,
|
||||||
y: panStartOffset.y + translation.y
|
y: panStartOffset.y + translation.y
|
||||||
)
|
)
|
||||||
applyTransform()
|
applyTransform()
|
||||||
} else {
|
|
||||||
// Not zoomed: detect axis
|
|
||||||
if !gestureAxisLocked {
|
|
||||||
let dx = abs(translation.x)
|
|
||||||
let dy = abs(translation.y)
|
|
||||||
// Android: abs(panChange.y) > abs(panChange.x) * 1.5
|
|
||||||
if dx > touchSlop || dy > touchSlop {
|
|
||||||
gestureAxisLocked = true
|
|
||||||
isDismissGesture = dy > dx * 1.2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDismissGesture {
|
|
||||||
dismissOffset = translation.y
|
|
||||||
let progress = min(abs(dismissOffset) / 300, 1.0)
|
|
||||||
onDismissProgress?(progress)
|
|
||||||
applyTransform()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .ended, .cancelled:
|
case .ended, .cancelled:
|
||||||
if currentScale > 1.05 {
|
|
||||||
clampOffset(animated: true)
|
clampOffset(animated: true)
|
||||||
} else if isDismissGesture {
|
|
||||||
let velocityY = abs(velocity.y)
|
|
||||||
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
|
|
||||||
// Dismiss with fade-out (Android: smoothDismiss 200ms fade)
|
|
||||||
onDismiss?()
|
|
||||||
} else {
|
|
||||||
// Snap back
|
|
||||||
dismissOffset = 0
|
|
||||||
onDismissCancel?()
|
|
||||||
applyTransform(animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isDismissGesture = false
|
|
||||||
gestureAxisLocked = false
|
gestureAxisLocked = false
|
||||||
|
|
||||||
default: break
|
default: break
|
||||||
@@ -429,13 +496,9 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
|
if gestureRecognizer is UIPanGestureRecognizer {
|
||||||
let velocity = pan.velocity(in: self)
|
// Pan only when zoomed — dismiss handled by SwiftUI DragGesture
|
||||||
|
return currentScale > 1.05
|
||||||
if currentScale <= 1.05 {
|
|
||||||
// Not zoomed: only begin for vertical-dominant drags.
|
|
||||||
// Let horizontal swipes pass through to TabView for paging.
|
|
||||||
return abs(velocity.y) > abs(velocity.x) * 1.2
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,6 +377,9 @@ private struct ToolbarTitleView: View {
|
|||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
.animation(.easeInOut(duration: 0.25), value: state)
|
.animation(.easeInOut(duration: 0.25), value: state)
|
||||||
|
.onTapGesture {
|
||||||
|
NotificationCenter.default.post(name: .chatListScrollToTop, object: nil)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ToolbarStatusLabel(title: "Connecting...")
|
ToolbarStatusLabel(title: "Connecting...")
|
||||||
}
|
}
|
||||||
@@ -589,7 +592,10 @@ private struct ChatListDialogContent: View {
|
|||||||
|
|
||||||
// MARK: - Dialog List
|
// MARK: - Dialog List
|
||||||
|
|
||||||
|
private static let topAnchorId = "chatlist_top"
|
||||||
|
|
||||||
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
|
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
|
||||||
|
ScrollViewReader { scrollProxy in
|
||||||
List {
|
List {
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
ForEach(0..<8, id: \.self) { _ in
|
ForEach(0..<8, id: \.self) { _ in
|
||||||
@@ -630,6 +636,17 @@ private struct ChatListDialogContent: View {
|
|||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.scrollDismissesKeyboard(.immediately)
|
.scrollDismissesKeyboard(.immediately)
|
||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
|
// Scroll-to-top: tap "Chats" in toolbar
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in
|
||||||
|
// Scroll to first dialog ID (pinned or unpinned)
|
||||||
|
let firstId = pinned.first?.id ?? unpinned.first?.id
|
||||||
|
if let firstId {
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) {
|
||||||
|
scrollProxy.scrollTo(firstId, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // ScrollViewReader
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
|
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
|
||||||
|
|||||||
@@ -358,10 +358,6 @@ struct SettingsView: View {
|
|||||||
themeCard
|
themeCard
|
||||||
safetyCard
|
safetyCard
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
debugCard
|
|
||||||
#endif
|
|
||||||
|
|
||||||
rosettaPowerFooter
|
rosettaPowerFooter
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ struct UpdatesView: View {
|
|||||||
versionCard
|
versionCard
|
||||||
helpText
|
helpText
|
||||||
checkButton
|
checkButton
|
||||||
releaseNotesSection
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
|
|||||||
@@ -344,4 +344,6 @@ extension Notification.Name {
|
|||||||
/// Posted when user taps an attachment in the bubble overlay — carries attachment ID (String) as `object`.
|
/// Posted when user taps an attachment in the bubble overlay — carries attachment ID (String) as `object`.
|
||||||
/// MessageImageView / MessageFileView listen and trigger download/share.
|
/// MessageImageView / MessageFileView listen and trigger download/share.
|
||||||
static let triggerAttachmentDownload = Notification.Name("triggerAttachmentDownload")
|
static let triggerAttachmentDownload = Notification.Name("triggerAttachmentDownload")
|
||||||
|
/// Posted when user taps "Chats" toolbar title — triggers scroll-to-top.
|
||||||
|
static let chatListScrollToTop = Notification.Name("chatListScrollToTop")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user