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