diff --git a/Info.plist b/Info.plist index bc11256..793300d 100644 --- a/Info.plist +++ b/Info.plist @@ -4,5 +4,9 @@ ITSAppUsesNonExemptEncryption + UIBackgroundModes + + remote-notification + diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 21571ad..fcf7c30 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; }; 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; + F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; + F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -30,6 +32,8 @@ files = ( 853F29992F4B63D20092AD05 /* Lottie in Frameworks */, 853F29A02F4B63D20092AD05 /* P256K in Frameworks */, + F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, + F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -74,6 +78,8 @@ packageProductDependencies = ( 853F29982F4B63D20092AD05 /* Lottie */, 853F29A12F4B63D20092AD05 /* P256K */, + F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */, + F1A000042F6F00010092AD05 /* FirebaseMessaging */, ); productName = Rosetta; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; @@ -106,6 +112,7 @@ packageReferences = ( 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */, 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */, + F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = 853F29632F4B50410092AD05 /* Products */; @@ -262,6 +269,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 9; @@ -300,6 +308,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 9; @@ -373,6 +382,14 @@ minimumVersion = 0.16.0; }; }; + F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 11.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -386,6 +403,16 @@ package = 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = P256K; }; + F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */ = { + isa = XCSwiftPackageProductDependency; + package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalyticsWithoutAdIdSupport; + }; + F1A000042F6F00010092AD05 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 853F295A2F4B50410092AD05 /* Project object */; diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift index 4d1b4f2..875b53d 100644 --- a/Rosetta/Core/Network/Protocol/Packets/Packet.swift +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -23,6 +23,7 @@ enum PacketRegistry { 0x07: { PacketRead() }, 0x08: { PacketDelivery() }, 0x0B: { PacketTyping() }, + 0x10: { PacketPushNotification() }, 0x17: { PacketDeviceList() }, 0x18: { PacketDeviceResolve() }, 0x19: { PacketSync() }, diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketPushNotification.swift b/Rosetta/Core/Network/Protocol/Packets/PacketPushNotification.swift new file mode 100644 index 0000000..1f668ff --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketPushNotification.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Action for push notification subscription. +enum PushNotificationAction: Int { + case subscribe = 0 + case unsubscribe = 1 +} + +/// PushNotification packet (0x10) — registers or unregisters APNs/FCM token on server. +/// Sent after successful handshake to enable push notifications. +/// Cross-platform compatible with Android PacketPushNotification. +struct PacketPushNotification: Packet { + static let packetId = 0x10 + + var notificationsToken: String = "" + var action: PushNotificationAction = .subscribe + + func write(to stream: Stream) { + stream.writeString(notificationsToken) + stream.writeInt8(action.rawValue) + } + + mutating func read(from stream: Stream) { + notificationsToken = stream.readString() + let actionValue = stream.readInt8() + action = PushNotificationAction(rawValue: actionValue) ?? .subscribe + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 70a3b7c..dc5a77b 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -110,7 +110,7 @@ final class SessionManager { // MARK: - Message Sending /// Sends an encrypted message to a recipient, matching Android's outgoing flow. - func sendMessage(text: String, toPublicKey: String) async throws { + func sendMessage(text: String, toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws { guard let privKey = privateKeyHex, let hash = privateKeyHash else { Self.logger.error("📤 Cannot send — missing keys") throw CryptoError.decryptionFailed @@ -130,12 +130,15 @@ final class SessionManager { privateKeyHash: hash ) - // Use existing dialog title/username instead of overwriting with empty strings + // Prefer caller-provided title/username (from ChatDetailView route), + // fall back to existing dialog data, then empty. let existingDialog = DialogRepository.shared.dialogs[toPublicKey] + let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "") + let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "") DialogRepository.shared.ensureDialog( opponentKey: toPublicKey, - title: existingDialog?.opponentTitle ?? "", - username: existingDialog?.opponentUsername ?? "", + title: title, + username: username, myPublicKey: currentPublicKey ) @@ -225,13 +228,16 @@ final class SessionManager { Task { @MainActor in let opponentKey = MessageRepository.shared.dialogKey(forMessageId: packet.messageId) ?? packet.toPublicKey - if MessageRepository.shared.isLatestMessage(packet.messageId, in: opponentKey) { - DialogRepository.shared.updateDeliveryStatus( - messageId: packet.messageId, - opponentKey: opponentKey, - status: .delivered - ) - } + // Always update dialog delivery status — downgrade guards in + // DialogRepository.updateDeliveryStatus already prevent + // .delivered → .waiting or .read → .delivered regressions. + // The old isLatestMessage guard caused dialog to stay stuck + // at .waiting when delivery ACKs arrived out of order. + DialogRepository.shared.updateDeliveryStatus( + messageId: packet.messageId, + opponentKey: opponentKey, + status: .delivered + ) // Desktop parity: update both status AND timestamp on delivery ACK. let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000) MessageRepository.shared.updateDeliveryStatus( @@ -353,6 +359,18 @@ final class SessionManager { // Clear dedup sets on reconnect so subscriptions can be re-established lazily. self.requestedUserInfoKeys.removeAll() self.onlineSubscribedKeys.removeAll() + + // 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() + } } } @@ -690,22 +708,46 @@ final class SessionManager { } /// After handshake, request user info for all existing dialog opponents. - /// This populates online status from search results (PacketSearch response includes `online` field). + /// 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). private func refreshOnlineStatusForAllDialogs() async { let dialogs = DialogRepository.shared.dialogs let ownKey = currentPublicKey - var count = 0 - for (key, _) in dialogs { + + // Split into priority (missing name) and normal (has name, refresh online) + var missingName: [String] = [] + var hasName: [String] = [] + for (key, dialog) in dialogs { guard key != ownKey, !key.isEmpty else { continue } + if dialog.opponentTitle.isEmpty { + missingName.append(key) + } else { + hasName.append(key) + } + } + + // Priority: fetch missing names first + 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)) + } + } + + // Then refresh online status for dialogs that already have names + for key in hasName { guard ProtocolManager.shared.connectionState == .authenticated else { break } requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash) count += 1 - // Stagger sends to avoid server RST from packet flood if count > 1 { try? await Task.sleep(for: .milliseconds(100)) } } - Self.logger.info("Refreshing online status for \(count) dialogs") + Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(hasName.count) online status = \(count) total") } /// Persistent handler for ALL search results — updates dialog names/usernames from server data. @@ -887,6 +929,33 @@ final class SessionManager { pendingOutgoingAttempts.removeValue(forKey: messageId) } + // MARK: - Push Notifications + + /// Stores the APNs device token received from AppDelegate. + /// Called from AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken. + func setAPNsToken(_ token: String) { + UserDefaults.standard.set(token, forKey: "apns_device_token") + // If already authenticated, send immediately + if ProtocolManager.shared.connectionState == .authenticated { + sendPushTokenToServer() + } + } + + /// Sends the stored APNs push token to the server via PacketPushNotification (0x10). + /// Android parity: called after successful handshake. + private func sendPushTokenToServer() { + guard let token = UserDefaults.standard.string(forKey: "apns_device_token"), + !token.isEmpty, + ProtocolManager.shared.connectionState == .authenticated + else { return } + + var packet = PacketPushNotification() + packet.notificationsToken = token + packet.action = .subscribe + ProtocolManager.shared.sendPacket(packet) + Self.logger.info("Push token sent to server") + } + // MARK: - Idle Detection Setup private func setupIdleDetection() { diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index 499e2a7..2c9d6d1 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -62,6 +62,8 @@ enum RosettaColors { enum Dark { static let background = Color.black static let backgroundSecondary = Color(hex: 0x2A2A2A) + /// Pinned chat section background (extends from toolbar to last pinned row) + static let pinnedSectionBackground = Color(hex: 0x1C1C1D) static let surface = Color(hex: 0x242424) static let text = Color.white static let textSecondary = Color(hex: 0x8E8E93) diff --git a/Rosetta/DesignSystem/Components/AvatarView.swift b/Rosetta/DesignSystem/Components/AvatarView.swift index 828a12d..e3b4ad6 100644 --- a/Rosetta/DesignSystem/Components/AvatarView.swift +++ b/Rosetta/DesignSystem/Components/AvatarView.swift @@ -1,5 +1,20 @@ import SwiftUI +// MARK: - Row Background Environment Key + +/// Lets parent views communicate their background color to descendants +/// so that elements like the online-indicator border can match dynamically. +private struct RowBackgroundColorKey: EnvironmentKey { + static let defaultValue: Color? = nil +} + +extension EnvironmentValues { + var rowBackgroundColor: Color? { + get { self[RowBackgroundColorKey.self] } + set { self[RowBackgroundColorKey.self] = newValue } + } +} + // MARK: - AvatarView struct AvatarView: View { @@ -8,8 +23,11 @@ struct AvatarView: View { let size: CGFloat var isOnline: Bool = false var isSavedMessages: Bool = false + /// Override for the online-indicator border (matches row background). + var onlineBorderColor: Color? @Environment(\.colorScheme) private var colorScheme + @Environment(\.rowBackgroundColor) private var rowBackgroundColor private var avatarPair: (tint: Color, text: Color) { RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count] @@ -60,7 +78,7 @@ struct AvatarView: View { .overlay { Circle() .stroke( - RosettaColors.Adaptive.background, + onlineBorderColor ?? rowBackgroundColor ?? RosettaColors.Adaptive.background, lineWidth: badgeBorderWidth ) } diff --git a/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift b/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift new file mode 100644 index 0000000..0df02c3 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift @@ -0,0 +1,224 @@ +import SwiftUI + +// MARK: - Bubble Position + +enum BubblePosition: Sendable, Equatable { + case single, top, mid, bottom +} + +// MARK: - Message Bubble Shape + +/// Unified message bubble shape: rounded-rect body + tail drawn as a **single fill**. +/// +/// The body and tail are two closed subpaths inside one `Path`. +/// Non-zero winding rule fills the overlap area seamlessly — +/// no anti-aliasing seam between body and tail. +/// +/// For positions without a tail (`.top`, `.mid`), only the body subpath is drawn. +/// For positions with a tail (`.single`, `.bottom`), both subpaths are drawn. +/// +/// The shape's `rect` includes space for the tail protrusion on the near side. +/// The body is inset from that side; the tail fills the protrusion area. +struct MessageBubbleShape: Shape { + let position: BubblePosition + let outgoing: Bool + let hasTail: Bool + + /// How far the tail protrudes beyond the bubble body edge (points). + static let tailProtrusion: CGFloat = 6 + + init(position: BubblePosition, outgoing: Bool) { + self.position = position + self.outgoing = outgoing + switch position { + case .single, .bottom: self.hasTail = true + case .top, .mid: self.hasTail = false + } + } + + func path(in rect: CGRect) -> Path { + var p = Path() + + // Body rect: inset on the near side when tail is present + let bodyRect: CGRect + if hasTail { + if outgoing { + bodyRect = CGRect(x: rect.minX, y: rect.minY, + width: rect.width - Self.tailProtrusion, height: rect.height) + } else { + bodyRect = CGRect(x: rect.minX + Self.tailProtrusion, y: rect.minY, + width: rect.width - Self.tailProtrusion, height: rect.height) + } + } else { + bodyRect = rect + } + + addBody(to: &p, rect: bodyRect) + + if hasTail { + addTail(to: &p, bodyRect: bodyRect) + } + + return p + } + + // MARK: - Body (Rounded Rect with Per-Corner Radii) + + private func addBody(to p: inout Path, rect: CGRect) { + let r: CGFloat = 18 + let s: CGFloat = 8 + let (tl, tr, bl, br) = cornerRadii(r: r, s: s) + + // Clamp to half the smallest dimension + let maxR = min(rect.width, rect.height) / 2 + let cTL = min(tl, maxR) + let cTR = min(tr, maxR) + let cBL = min(bl, maxR) + let cBR = min(br, maxR) + + p.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY)) + + // Top edge → top-right corner + p.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY)) + p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY), + tangent2End: CGPoint(x: rect.maxX, y: rect.minY + cTR), + radius: cTR) + + // Right edge → bottom-right corner + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR)) + p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), + tangent2End: CGPoint(x: rect.maxX - cBR, y: rect.maxY), + radius: cBR) + + // Bottom edge → bottom-left corner + p.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY)) + p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY), + tangent2End: CGPoint(x: rect.minX, y: rect.maxY - cBL), + radius: cBL) + + // Left edge → top-left corner + p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL)) + p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), + tangent2End: CGPoint(x: rect.minX + cTL, y: rect.minY), + radius: cTL) + + p.closeSubpath() + } + + /// Figma corner radii: 8px on "connecting" side, 18px elsewhere. + private func cornerRadii(r: CGFloat, s: CGFloat) + -> (topLeading: CGFloat, topTrailing: CGFloat, + bottomLeading: CGFloat, bottomTrailing: CGFloat) { + switch position { + case .single: + return (r, r, r, r) + case .top: + return outgoing + ? (r, r, r, s) + : (r, r, s, r) + case .mid: + return outgoing + ? (r, s, r, s) + : (s, r, s, r) + case .bottom: + return outgoing + ? (r, s, r, r) + : (s, r, r, r) + } + } + + // MARK: - Tail (Figma SVG — separate subpath) + + /// Draws the tail as a second closed subpath that overlaps the body at the + /// bottom-near corner. Both subpaths are filled together in one `.fill()` call, + /// so the overlapping area has no visible seam. + /// + /// Uses the exact Figma SVG path (viewBox 0 0 13.6216 33.3). + /// Raw SVG: straight edge at x≈5.6, tip protrudes LEFT to x=0. + /// The `dir` multiplier flips the protrusion direction for outgoing. + private func addTail(to p: inout Path, bodyRect: CGRect) { + // Figma SVG straight edge X — defines the body attachment line + let svgStraightX: CGFloat = 5.59961 + let svgMaxY: CGFloat = 33.2305 + + // Uniform scale: maps SVG protrusion (5.6 units) to screen protrusion + let sc = Self.tailProtrusion / svgStraightX + + // Tail height in points + let tailH = svgMaxY * sc + + let bodyEdge = outgoing ? bodyRect.maxX : bodyRect.minX + let bottom = bodyRect.maxY + let top = bottom - tailH + + // +1 = protrude RIGHT (outgoing), −1 = protrude LEFT (incoming) + let dir: CGFloat = outgoing ? 1 : -1 + + // Map raw Figma SVG coord → screen coord + func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint { + let dx = (svgStraightX - svgX) * sc * dir + return CGPoint(x: bodyEdge + dx, y: top + svgY * sc) + } + + // -- Exact Figma SVG path (from Figma API, viewBox 0 0 13.6216 33.3) -- + // M5.59961 24.2305 + // C5.42042 28.0524 3.19779 31.339 0 33.0244 + // C0.851596 33.1596 1.72394 33.2305 2.6123 33.2305 + // C6.53776 33.2305 10.1517 31.8599 13.0293 29.5596 + // C10.7434 27.898 8.86922 25.7134 7.57422 23.1719 + // C5.61235 19.3215 5.6123 14.281 5.6123 4.2002 + // V0 H5.59961 V24.2305 Z + + if outgoing { + // Forward order — clockwise winding (matches body) + p.move(to: tp(5.59961, 24.2305)) + p.addCurve(to: tp(0, 33.0244), + control1: tp(5.42042, 28.0524), + control2: tp(3.19779, 31.339)) + p.addCurve(to: tp(2.6123, 33.2305), + control1: tp(0.851596, 33.1596), + control2: tp(1.72394, 33.2305)) + p.addCurve(to: tp(13.0293, 29.5596), + control1: tp(6.53776, 33.2305), + control2: tp(10.1517, 31.8599)) + p.addCurve(to: tp(7.57422, 23.1719), + control1: tp(10.7434, 27.898), + control2: tp(8.86922, 25.7134)) + p.addCurve(to: tp(5.6123, 4.2002), + control1: tp(5.61235, 19.3215), + control2: tp(5.6123, 14.281)) + p.addLine(to: tp(5.6123, 0)) + p.addLine(to: tp(5.59961, 0)) + p.addLine(to: tp(5.59961, 24.2305)) + p.closeSubpath() + } else { + // Reversed order — clockwise winding for incoming + // (mirroring X flips winding; reversing path order restores it) + p.move(to: tp(5.59961, 24.2305)) + p.addLine(to: tp(5.59961, 0)) + p.addLine(to: tp(5.6123, 0)) + p.addLine(to: tp(5.6123, 4.2002)) + // Curve 5 reversed (swap control points) + p.addCurve(to: tp(7.57422, 23.1719), + control1: tp(5.6123, 14.281), + control2: tp(5.61235, 19.3215)) + // Curve 4 reversed + p.addCurve(to: tp(13.0293, 29.5596), + control1: tp(8.86922, 25.7134), + control2: tp(10.7434, 27.898)) + // Curve 3 reversed + p.addCurve(to: tp(2.6123, 33.2305), + control1: tp(10.1517, 31.8599), + control2: tp(6.53776, 33.2305)) + // Curve 2 reversed + p.addCurve(to: tp(0, 33.0244), + control1: tp(1.72394, 33.2305), + control2: tp(0.851596, 33.1596)) + // Curve 1 reversed + p.addCurve(to: tp(5.59961, 24.2305), + control1: tp(3.19779, 31.339), + control2: tp(5.42042, 28.0524)) + p.closeSubpath() + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 1b21c59..7be9f6c 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -137,57 +137,118 @@ private extension ChatDetailView { @ToolbarContentBuilder var chatDetailToolbar: some ToolbarContent { - ToolbarItem(placement: .navigationBarLeading) { - Button { dismiss() } label: { backCapsuleButtonLabel } - .buttonStyle(.plain) - .accessibilityLabel("Back") - } - - ToolbarItem(placement: .principal) { - Button { dismiss() } label: { - VStack(spacing: 1) { - HStack(spacing: 4) { - Text(titleText) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - .lineLimit(1) - - if !route.isSavedMessages && effectiveVerified > 0 { - VerifiedBadge(verified: effectiveVerified, size: 14) - } - } - - Text(subtitleText) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle( - isTyping || (dialog?.isOnline == true) - ? RosettaColors.online - : RosettaColors.Adaptive.textSecondary - ) - .lineLimit(1) + if #available(iOS 26, *) { + // iOS 26+ — original compact sizes with .glassEffect() + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { + TelegramVectorIcon( + pathData: TelegramIconPath.backChevron, + viewBox: CGSize(width: 11, height: 20), + color: .white + ) + .frame(width: 11, height: 20) + .allowsHitTesting(false) } - .padding(.horizontal, 16) - .frame(height: 44) - .background { - glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white) + .frame(width: 36, height: 36) + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + .accessibilityLabel("Back") + } + + ToolbarItem(placement: .principal) { + Button { dismiss() } label: { + VStack(spacing: 1) { + HStack(spacing: 3) { + Text(titleText) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if !route.isSavedMessages && effectiveVerified > 0 { + VerifiedBadge(verified: effectiveVerified, size: 12) + } + } + + Text(subtitleText) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle( + isTyping || (dialog?.isOnline == true) + ? RosettaColors.online + : RosettaColors.Adaptive.textSecondary + ) + .lineLimit(1) + } + .padding(.horizontal, 12) + .frame(height: 44) + .background { + glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white) + } } } - .buttonStyle(.plain) - } - ToolbarItem(placement: .navigationBarTrailing) { - AvatarView( - initials: avatarInitials, - colorIndex: avatarColorIndex, - size: 38, - isOnline: false, - isSavedMessages: route.isSavedMessages - ) - .frame(width: 44, height: 44) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + ToolbarItem(placement: .navigationBarTrailing) { + AvatarView( + initials: avatarInitials, + colorIndex: avatarColorIndex, + size: 35, + isOnline: false, + isSavedMessages: route.isSavedMessages + ) + .frame(width: 36, height: 36) + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + } + } else { + // iOS < 26 — capsule back button, larger avatar, .thinMaterial + ToolbarItem(placement: .navigationBarLeading) { + Button { dismiss() } label: { backCapsuleButtonLabel } + .buttonStyle(.plain) + .accessibilityLabel("Back") + } + + ToolbarItem(placement: .principal) { + Button { dismiss() } label: { + VStack(spacing: 1) { + HStack(spacing: 4) { + Text(titleText) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if !route.isSavedMessages && effectiveVerified > 0 { + VerifiedBadge(verified: effectiveVerified, size: 14) + } + } + + Text(subtitleText) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle( + isTyping || (dialog?.isOnline == true) + ? RosettaColors.online + : RosettaColors.Adaptive.textSecondary + ) + .lineLimit(1) + } + .padding(.horizontal, 16) + .frame(height: 44) + .background { + glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white) + } + } + .buttonStyle(.plain) + } + + ToolbarItem(placement: .navigationBarTrailing) { + AvatarView( + initials: avatarInitials, + colorIndex: avatarColorIndex, + size: 38, + isOnline: false, + isSavedMessages: route.isSavedMessages + ) + .frame(width: 44, height: 44) + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + } } } - private var backCapsuleButtonLabel: some View { TelegramVectorIcon( @@ -322,12 +383,12 @@ private extension ChatDetailView { private func messagesScrollView(maxBubbleWidth: CGFloat) -> some View { ScrollViewReader { proxy in let scroll = ScrollView(.vertical, showsIndicators: false) { - LazyVStack(spacing: 6) { + LazyVStack(spacing: 0) { ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in messageRow( message, maxBubbleWidth: maxBubbleWidth, - isTailVisible: isTailVisible(for: index) + position: bubblePosition(for: index) ) .id(message.id) } @@ -375,9 +436,10 @@ private extension ChatDetailView { } @ViewBuilder - func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, isTailVisible: Bool) -> some View { + func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View { let outgoing = message.isFromMe(myPublicKey: currentPublicKey) let messageText = message.text.isEmpty ? " " : message.text + let hasTail = position == .single || position == .bottom // Telegram-style compact bubble: inline time+status at bottom-trailing. // Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming). @@ -407,10 +469,17 @@ private extension ChatDetailView { .padding(.trailing, 11) .padding(.bottom, 5) } - .background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) } + // Tail protrusion space: the unified shape draws the tail in this padding area + .padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + .padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + // Single unified background: body + tail drawn in one fill (no seam) + .background { bubbleBackground(outgoing: outgoing, position: position) } .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) .frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading) - .padding(.vertical, 1) + .padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) + .padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) + .padding(.top, (position == .single || position == .top) ? 6 : 2) + .padding(.bottom, 0) } // MARK: - Composer @@ -597,28 +666,43 @@ private extension ChatDetailView { } } + // MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom) + + /// Determines bubble position within a group of consecutive same-sender plain-text messages. + func bubblePosition(for index: Int) -> BubblePosition { + let hasPrev: Bool = { + guard index > 0 else { return false } + let prev = messages[index - 1] + let current = messages[index] + let sameSender = current.isFromMe(myPublicKey: currentPublicKey) + == prev.isFromMe(myPublicKey: currentPublicKey) + return sameSender && prev.attachments.isEmpty && current.attachments.isEmpty + }() + + let hasNext: Bool = { + guard index + 1 < messages.count else { return false } + let next = messages[index + 1] + let current = messages[index] + let sameSender = current.isFromMe(myPublicKey: currentPublicKey) + == next.isFromMe(myPublicKey: currentPublicKey) + return sameSender && next.attachments.isEmpty && current.attachments.isEmpty + }() + + switch (hasPrev, hasNext) { + case (false, false): return .single + case (false, true): return .top + case (true, true): return .mid + case (true, false): return .bottom + } + } + // MARK: - Bubbles / Glass @ViewBuilder - func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View { - let nearRadius: CGFloat = isTailVisible ? 8 : 18 - let bubbleRadius: CGFloat = 18 + func bubbleBackground(outgoing: Bool, position: BubblePosition) -> some View { let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill - if #available(iOS 17.0, *) { - UnevenRoundedRectangle( - cornerRadii: .init( - topLeading: bubbleRadius, - bottomLeading: outgoing ? bubbleRadius : nearRadius, - bottomTrailing: outgoing ? nearRadius : bubbleRadius, - topTrailing: bubbleRadius - ), - style: .continuous - ) + MessageBubbleShape(position: position, outgoing: outgoing) .fill(fill) - } else { - RoundedRectangle(cornerRadius: bubbleRadius, style: .continuous) - .fill(fill) - } } enum ChatGlassShape { @@ -721,18 +805,7 @@ private extension ChatDetailView { } } - func isTailVisible(for index: Int) -> Bool { - guard index < messages.count else { return true } - let current = messages[index] - guard index + 1 < messages.count else { return true } - let next = messages[index + 1] - let sameSender = current.isFromMe(myPublicKey: currentPublicKey) == next.isFromMe(myPublicKey: currentPublicKey) - - let currentIsPlainText = current.attachments.isEmpty - let nextIsPlainText = next.attachments.isEmpty - - return !(sameSender && currentIsPlainText && nextIsPlainText) - } + // isTailVisible replaced by bubblePosition(for:) above func requestUserInfoIfNeeded() { // Always request — we need fresh online status even if title is already populated. @@ -770,7 +843,12 @@ private extension ChatDetailView { Task { @MainActor in do { - try await SessionManager.shared.sendMessage(text: message, toPublicKey: route.publicKey) + try await SessionManager.shared.sendMessage( + text: message, + toPublicKey: route.publicKey, + opponentTitle: route.title, + opponentUsername: route.username + ) } catch { sendError = "Failed to send message" if messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index 6e5156b..bcd79cb 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -23,10 +23,16 @@ struct ChatListSearchContent: View { // MARK: - Active Search (Three States) private extension ChatListSearchContent { - /// Android-style: skeleton ↔ empty ↔ results — only one visible at a time. + /// Desktop-parity: skeleton ↔ empty ↔ results — only one visible at a time. + /// Local filtering uses `searchText` directly (NOT viewModel.searchQuery) + /// to avoid @Published re-render cascade through ChatListView. @ViewBuilder var activeSearchContent: some View { - let localResults = viewModel.filteredDialogs + let query = searchText.trimmingCharacters(in: .whitespaces).lowercased() + // Local results: match by username ONLY (desktop parity — server matches usernames) + let localResults = DialogRepository.shared.sortedDialogs.filter { dialog in + !query.isEmpty && dialog.opponentUsername.lowercased().contains(query) + } let localKeys = Set(localResults.map(\.opponentKey)) let serverOnly = viewModel.serverSearchResults.filter { !localKeys.contains($0.publicKey) @@ -63,6 +69,7 @@ private extension ChatListSearchContent { } /// Scrollable list of local dialogs + server results. + /// Shows skeleton rows at the bottom while server is still searching. func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View { ScrollView { LazyVStack(spacing: 0) { @@ -84,11 +91,23 @@ private extension ChatListSearchContent { } } + // Skeleton loading rows while server search in progress + if viewModel.isServerSearching { + searchSkeletonRows + } + Spacer().frame(height: 80) } } .scrollDismissesKeyboard(.immediately) } + + /// Inline skeleton rows (3 shimmer placeholders) shown below existing results. + private var searchSkeletonRows: some View { + ForEach(0..<3, id: \.self) { _ in + SearchSkeletonRow() + } + } } // MARK: - Recent Searches diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index e3f6d38..edea7b8 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -29,6 +29,7 @@ struct ChatListView: View { @StateObject private var viewModel = ChatListViewModel() @StateObject private var navigationState = ChatListNavigationState() @State private var searchText = "" + @State private var hasPinnedChats = false @FocusState private var isSearchFocused: Bool @MainActor static var _bodyCount = 0 @@ -42,6 +43,12 @@ struct ChatListView: View { .padding(.horizontal, 16) .padding(.top, 6) .padding(.bottom, 8) + .background( + (hasPinnedChats && !isSearchActive + ? RosettaColors.Dark.pinnedSectionBackground + : Color.clear + ).ignoresSafeArea(.all, edges: .top) + ) if isSearchActive { ChatListSearchContent( @@ -68,7 +75,7 @@ struct ChatListView: View { .navigationBarTitleDisplayMode(.inline) .toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar) .toolbar { toolbarContent } - .toolbarBackground(.hidden, for: .navigationBar) + .modifier(ChatListToolbarBackgroundModifier()) .onChange(of: searchText) { _, newValue in viewModel.setSearchQuery(newValue) } @@ -223,7 +230,14 @@ private extension ChatListView { // without polluting ChatListView's observation scope. ChatListDialogContent( viewModel: viewModel, - navigationState: navigationState + navigationState: navigationState, + onPinnedStateChange: { pinned in + if hasPinnedChats != pinned { + withAnimation(.easeInOut(duration: 0.25)) { + hasPinnedChats = pinned + } + } + } ) } } @@ -235,60 +249,112 @@ private extension ChatListView { @ToolbarContentBuilder var toolbarContent: some ToolbarContent { if !isSearchActive { - ToolbarItem(placement: .navigationBarLeading) { - Button { } label: { - Text("Edit") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.text) - .frame(height: 44) - .padding(.horizontal, 10) - } - .buttonStyle(.plain) - .glassCapsule() - } - - ToolbarItem(placement: .principal) { - HStack(spacing: 4) { - // Isolated view — reads AccountManager & SessionManager (@Observable) - // without polluting ChatListView's observation scope. - ToolbarStoriesAvatar() - Text("Chats") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - } - } - - ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 0) { + if #available(iOS 26, *) { + // iOS 26+ — original compact toolbar (no capsules, system icons) + ToolbarItem(placement: .navigationBarLeading) { Button { } label: { - Image("toolbar-add-chat") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 22, height: 22) - .frame(width: 44, height: 44) + Text("Edit") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + + ToolbarItem(placement: .principal) { + HStack(spacing: 4) { + ToolbarStoriesAvatar() + Text("Chats") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + + ToolbarItemGroup(placement: .navigationBarTrailing) { + HStack(spacing: 8) { + Button { } label: { + Image(systemName: "camera") + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + .accessibilityLabel("Camera") + Button { } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + .padding(.bottom, 2) + .accessibilityLabel("New chat") + } + } + } else { + // iOS < 26 — capsule-styled toolbar with custom icons + ToolbarItem(placement: .navigationBarLeading) { + Button { } label: { + Text("Edit") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(height: 44) + .padding(.horizontal, 10) } .buttonStyle(.plain) - .accessibilityLabel("Add chat") - - Button { } label: { - Image("toolbar-compose") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel("New chat") + .glassCapsule() + } + + ToolbarItem(placement: .principal) { + HStack(spacing: 4) { + ToolbarStoriesAvatar() + Text("Chats") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 0) { + Button { } label: { + Image("toolbar-add-chat") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("Add chat") + + Button { } label: { + Image("toolbar-compose") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("New chat") + } + .foregroundStyle(RosettaColors.Adaptive.text) + .glassCapsule() } - .foregroundStyle(RosettaColors.Adaptive.text) - .glassCapsule() } } } } +// MARK: - Toolbar Background Modifier + +private struct ChatListToolbarBackgroundModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + .toolbarBackground(.visible, for: .navigationBar) + .applyGlassNavBar() + } else { + content + .toolbarBackground(.hidden, for: .navigationBar) + } + } +} + // MARK: - Toolbar Stories Avatar (observation-isolated) /// Reads `AccountManager` and `SessionManager` in its own observation scope. @@ -341,15 +407,21 @@ private struct DeviceVerificationBannersContainer: View { 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: "") + .onChange(of: hasPinned) { _, val in onPinnedStateChange(val) } + .onAppear { onPinnedStateChange(hasPinned) } } else { dialogList + .onChange(of: hasPinned) { _, val in onPinnedStateChange(val) } + .onAppear { onPinnedStateChange(hasPinned) } } } @@ -366,7 +438,8 @@ private struct ChatListDialogContent: View { if !viewModel.pinnedDialogs.isEmpty { ForEach(Array(viewModel.pinnedDialogs.enumerated()), id: \.element.id) { index, dialog in chatRow(dialog, isFirst: index == 0) - .listRowBackground(RosettaColors.Adaptive.backgroundSecondary) + .environment(\.rowBackgroundColor, RosettaColors.Dark.pinnedSectionBackground) + .listRowBackground(RosettaColors.Dark.pinnedSectionBackground) } } ForEach(Array(viewModel.unpinnedDialogs.enumerated()), id: \.element.id) { index, dialog in @@ -397,11 +470,6 @@ private struct ChatListDialogContent: View { .listRowSeparatorTint(RosettaColors.Adaptive.divider) .alignmentGuide(.listRowSeparatorLeading) { _ in 82 } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - withAnimation { viewModel.deleteDialog(dialog) } - } label: { - Label("Delete", systemImage: "trash") - } Button { viewModel.toggleMute(dialog) } label: { @@ -410,7 +478,7 @@ private struct ChatListDialogContent: View { systemImage: dialog.isMuted ? "bell" : "bell.slash" ) } - .tint(.indigo) + .tint(dialog.isMuted ? .green : .indigo) } .swipeActions(edge: .leading, allowsFullSwipe: true) { Button { @@ -422,7 +490,7 @@ private struct ChatListDialogContent: View { Button { viewModel.togglePin(dialog) } label: { - Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: "pin") + Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin") } .tint(.orange) } diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift index cf7e341..df46b7e 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -12,14 +12,15 @@ final class ChatListViewModel: ObservableObject { // MARK: - State @Published var isLoading = false - @Published var searchQuery = "" + /// NOT @Published — avoids 2× body re-renders per keystroke in ChatListView. + /// Local filtering uses `searchText` param directly in ChatListSearchContent. + var searchQuery = "" @Published var serverSearchResults: [SearchUser] = [] @Published var isServerSearching = false @Published var recentSearches: [RecentSearch] = [] private var searchTask: Task? private var searchRetryTask: Task? - private var lastSearchedText = "" private var searchHandlerToken: UUID? private var recentSearchesCancellable: AnyCancellable? private let recentRepository = RecentSearchesRepository.shared @@ -32,19 +33,13 @@ final class ChatListViewModel: ObservableObject { } - // MARK: - Computed (local dialog filtering) + // MARK: - Computed (dialog list for ChatListDialogContent) + /// Full dialog list — used by ChatListDialogContent which is only visible + /// when search is NOT active. Search filtering is done separately in + /// ChatListSearchContent using `searchText` parameter directly. var filteredDialogs: [Dialog] { - var result = DialogRepository.shared.sortedDialogs - let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased() - if !query.isEmpty { - result = result.filter { - $0.opponentTitle.lowercased().contains(query) - || $0.opponentUsername.lowercased().contains(query) - || $0.lastMessage.lowercased().contains(query) - } - } - return result + DialogRepository.shared.sortedDialogs } var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) } @@ -120,17 +115,19 @@ final class ChatListViewModel: ObservableObject { private func triggerServerSearch() { searchTask?.cancel() searchTask = nil + searchRetryTask?.cancel() + searchRetryTask = nil let trimmed = searchQuery.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { - serverSearchResults = [] - isServerSearching = false - lastSearchedText = "" + // Guard: only publish if value actually changes (avoids extra re-renders) + if !serverSearchResults.isEmpty { serverSearchResults = [] } + if isServerSearching { isServerSearching = false } return } - if trimmed == lastSearchedText { return } - isServerSearching = true + // Guard: don't re-publish true when already true + if !isServerSearching { isServerSearching = true } searchTask = Task { [weak self] in try? await Task.sleep(for: .seconds(1)) @@ -139,36 +136,45 @@ final class ChatListViewModel: ObservableObject { let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) guard !currentQuery.isEmpty, currentQuery == trimmed else { return } - let connState = ProtocolManager.shared.connectionState - let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash - - guard connState == .authenticated, let hash else { - self.isServerSearching = false - // Reset so next attempt re-sends instead of being de-duped - self.lastSearchedText = "" - // Retry after 2 seconds if still have a query - self.scheduleSearchRetry() - return - } - - self.lastSearchedText = currentQuery - var packet = PacketSearch() - packet.privateKey = hash - packet.search = currentQuery - Self.logger.debug("📤 Sending search packet for '\(currentQuery)' with hash \(hash.prefix(10))...") - ProtocolManager.shared.sendPacket(packet) + self.sendSearchPacket(query: currentQuery) } } - private func scheduleSearchRetry() { - searchRetryTask?.cancel() - searchRetryTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(2)) - guard let self, !Task.isCancelled else { return } - let q = self.searchQuery.trimmingCharacters(in: .whitespaces) - guard !q.isEmpty else { return } - self.triggerServerSearch() + /// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s). + private func sendSearchPacket(query: String) { + let connState = ProtocolManager.shared.connectionState + let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash + + guard connState == .authenticated, let hash else { + // Not authenticated — wait for reconnect then send + Self.logger.debug("Search deferred — waiting for authentication") + searchRetryTask?.cancel() + searchRetryTask = Task { [weak self] in + // Poll every 500ms for up to 10s (covers 5s reconnect + handshake) + for _ in 0..<20 { + try? await Task.sleep(for: .milliseconds(500)) + guard let self, !Task.isCancelled else { return } + let current = self.searchQuery.trimmingCharacters(in: .whitespaces) + guard current == query else { return } // Query changed, abort + if ProtocolManager.shared.connectionState == .authenticated { + Self.logger.debug("Connection restored — sending pending search") + self.sendSearchPacket(query: query) + return + } + } + // Timed out + guard let self else { return } + Self.logger.warning("Search timed out waiting for authentication") + self.isServerSearching = false + } + return } + + var packet = PacketSearch() + packet.privateKey = hash + packet.search = query + Self.logger.debug("📤 Sending search packet for '\(query)'") + ProtocolManager.shared.sendPacket(packet) } private func normalizeSearchInput(_ input: String) -> String { diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index d4917c7..e73cbc7 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -173,17 +173,27 @@ private extension ChatRowView { } } + /// Desktop parity: clock only within 80s of send, then error. + /// Delivered → single check, Read → double checks. + private static let maxWaitingSeconds: TimeInterval = 80 + @ViewBuilder var deliveryIcon: some View { switch dialog.lastMessageDelivered { case .waiting: - Image(systemName: "clock") - .font(.system(size: 13)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) + if isWithinWaitingWindow { + Image(systemName: "clock") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } else { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.error) + } case .delivered: Image(systemName: "checkmark") .font(.system(size: 12, weight: .bold)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .foregroundStyle(RosettaColors.figmaBlue) case .read: Image(systemName: "checkmark") .font(.system(size: 12, weight: .bold)) @@ -202,6 +212,12 @@ 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 + } + var unreadBadge: some View { let count = dialog.unreadCount let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)") diff --git a/Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift b/Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift index 6856b8a..1c0908c 100644 --- a/Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift +++ b/Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift @@ -84,3 +84,50 @@ struct SearchSkeletonView: View { ) } } + +// MARK: - SearchSkeletonRow + +/// Single shimmer row matching `serverUserRow` layout (48px avatar, two text lines). +/// Used inline below existing search results while server is still loading. +struct SearchSkeletonRow: View { + @State private var phase: CGFloat = 0 + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(shimmerGradient) + .frame(width: 48, height: 48) + + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 120, height: 14) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 90, height: 12) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .task { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } + + private var shimmerGradient: LinearGradient { + LinearGradient( + colors: [ + Color.gray.opacity(0.08), + Color.gray.opacity(0.15), + Color.gray.opacity(0.08), + ], + startPoint: UnitPoint(x: phase - 0.4, y: 0), + endPoint: UnitPoint(x: phase + 0.4, y: 0) + ) + } +} diff --git a/Rosetta/GoogleService-Info.plist b/Rosetta/GoogleService-Info.plist new file mode 100644 index 0000000..efbe2f9 --- /dev/null +++ b/Rosetta/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyAzmKK-uGnhYaCpW80rajzozFB_T09EHvs + GCM_SENDER_ID + 309962873774 + PLIST_VERSION + 1 + BUNDLE_ID + com.rosetta.dev + PROJECT_ID + rosetta-messanger-dev + STORAGE_BUCKET + rosetta-messanger-dev.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:309962873774:ios:e0b6859a4465ac4c5ac63f + + \ No newline at end of file diff --git a/Rosetta/Rosetta.entitlements b/Rosetta/Rosetta.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/Rosetta/Rosetta.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 717113f..81c94c9 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -1,4 +1,85 @@ +import FirebaseCore +import FirebaseMessaging import SwiftUI +import UserNotifications + +// MARK: - Firebase AppDelegate + +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, + MessagingDelegate +{ + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + FirebaseApp.configure() + + // Set delegates + Messaging.messaging().delegate = self + UNUserNotificationCenter.current().delegate = self + + // Request notification permission + UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .badge, .sound] + ) { granted, _ in + if granted { + DispatchQueue.main.async { + application.registerForRemoteNotifications() + } + } + } + + return true + } + + // Forward APNs token to Firebase Messaging + SessionManager + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Messaging.messaging().apnsToken = deviceToken + } + + // MARK: - MessagingDelegate + + /// Called when FCM token is received or refreshed. + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + guard let token = fcmToken else { return } + Task { @MainActor in + SessionManager.shared.setAPNsToken(token) + } + } + + // MARK: - UNUserNotificationCenterDelegate + + /// Handle foreground notifications — suppress when app is active (Android parity). + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> + Void + ) { + let userInfo = notification.request.content.userInfo + let type = userInfo["type"] as? String + + // Suppress foreground notifications (Android parity: isAppInForeground check) + if type == "new_message" { + completionHandler([]) + } else { + completionHandler([.banner, .badge, .sound]) + } + } + + /// Handle notification tap — navigate to chat. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // TODO: Navigate to specific chat using sender_public_key from payload + completionHandler() + } +} // MARK: - App State @@ -13,6 +94,7 @@ private enum AppState { @main struct RosettaApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate init() { UIWindow.appearance().backgroundColor = .black