Исправление winding direction хвостика incoming-баблов + выравнивание баблов в группе

This commit is contained in:
2026-03-10 19:31:09 +05:00
parent 2cc780201d
commit 0f5094df10
17 changed files with 929 additions and 202 deletions

View File

@@ -4,5 +4,9 @@
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>

View File

@@ -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 */;

View File

@@ -23,6 +23,7 @@ enum PacketRegistry {
0x07: { PacketRead() },
0x08: { PacketDelivery() },
0x0B: { PacketTyping() },
0x10: { PacketPushNotification() },
0x17: { PacketDeviceList() },
0x18: { PacketDeviceResolve() },
0x19: { PacketSync() },

View File

@@ -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
}
}

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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 x5.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()
}
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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<Void, Never>?
private var searchRetryTask: Task<Void, Never>?
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 {

View File

@@ -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)")

View File

@@ -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)
)
}
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyAzmKK-uGnhYaCpW80rajzozFB_T09EHvs</string>
<key>GCM_SENDER_ID</key>
<string>309962873774</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.rosetta.dev</string>
<key>PROJECT_ID</key>
<string>rosetta-messanger-dev</string>
<key>STORAGE_BUCKET</key>
<string>rosetta-messanger-dev.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:309962873774:ios:e0b6859a4465ac4c5ac63f</string>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View File

@@ -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