Исправление winding direction хвостика incoming-баблов + выравнивание баблов в группе
This commit is contained in:
@@ -4,5 +4,9 @@
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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 */;
|
||||
|
||||
@@ -23,6 +23,7 @@ enum PacketRegistry {
|
||||
0x07: { PacketRead() },
|
||||
0x08: { PacketDelivery() },
|
||||
0x0B: { PacketTyping() },
|
||||
0x10: { PacketPushNotification() },
|
||||
0x17: { PacketDeviceList() },
|
||||
0x18: { PacketDeviceResolve() },
|
||||
0x19: { PacketSync() },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
224
Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift
Normal file
224
Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift
Normal 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 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
30
Rosetta/GoogleService-Info.plist
Normal file
30
Rosetta/GoogleService-Info.plist
Normal 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>
|
||||
8
Rosetta/Rosetta.entitlements
Normal file
8
Rosetta/Rosetta.entitlements
Normal 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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user