Кросс-платформенное шифрование фото/аватаров, профиль собеседника, вложения в чате

This commit is contained in:
2026-03-16 05:57:07 +05:00
parent dd4642f251
commit 624038915d
43 changed files with 5212 additions and 656 deletions

View File

@@ -20,7 +20,7 @@ final class ChatListNavigationState: ObservableObject {
/// times per frame" app freeze.
///
/// All `@Observable` access is isolated in dedicated child views:
/// - `DeviceVerificationBannersContainer` `ProtocolManager`
/// - `DeviceVerificationContentRouter` `ProtocolManager`
/// - `ToolbarStoriesAvatar` `AccountManager` / `SessionManager`
/// - `ChatListDialogContent` `DialogRepository` (via ViewModel)
struct ChatListView: View {
@@ -30,6 +30,7 @@ struct ChatListView: View {
@StateObject private var navigationState = ChatListNavigationState()
@State private var searchText = ""
@State private var hasPinnedChats = false
@State private var showRequestChats = false
@FocusState private var isSearchFocused: Bool
var body: some View {
@@ -84,11 +85,20 @@ struct ChatListView: View {
}
)
}
.navigationDestination(isPresented: $showRequestChats) {
RequestChatsView(
viewModel: viewModel,
navigationState: navigationState
)
}
.onAppear {
isDetailPresented = !navigationState.path.isEmpty
isDetailPresented = !navigationState.path.isEmpty || showRequestChats
}
.onChange(of: navigationState.path) { _, newPath in
isDetailPresented = !newPath.isEmpty
isDetailPresented = !newPath.isEmpty || showRequestChats
}
.onChange(of: showRequestChats) { _, showing in
isDetailPresented = !navigationState.path.isEmpty || showing
}
}
.tint(RosettaColors.figmaBlue)
@@ -223,25 +233,21 @@ private extension ChatListView {
private extension ChatListView {
@ViewBuilder
var normalContent: some View {
VStack(spacing: 0) {
// Isolated view reads ProtocolManager (@Observable) without
// polluting ChatListView's observation scope.
DeviceVerificationBannersContainer()
// Isolated view reads DialogRepository (@Observable) via viewModel
// without polluting ChatListView's observation scope.
ChatListDialogContent(
viewModel: viewModel,
navigationState: navigationState,
onPinnedStateChange: { pinned in
if hasPinnedChats != pinned {
withAnimation(.easeInOut(duration: 0.25)) {
hasPinnedChats = pinned
}
// Observation-isolated router reads ProtocolManager in its own scope.
// Shows full-screen DeviceConfirmView when awaiting approval,
// or normal chat list with optional device approval banner otherwise.
DeviceVerificationContentRouter(
viewModel: viewModel,
navigationState: navigationState,
onShowRequests: { showRequestChats = true },
onPinnedStateChange: { pinned in
if hasPinnedChats != pinned {
withAnimation(.easeInOut(duration: 0.25)) {
hasPinnedChats = pinned
}
}
)
}
}
)
}
}
@@ -340,7 +346,7 @@ private extension ChatListView {
// MARK: - Toolbar Background Modifier
private struct ChatListToolbarBackgroundModifier: ViewModifier {
struct ChatListToolbarBackgroundModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
@@ -364,49 +370,38 @@ private struct ToolbarTitleView: View {
let isSyncing = SessionManager.shared.syncBatchInProgress
if state == .authenticated && isSyncing {
UpdatingDotsView()
} else {
let title: String = switch state {
case .authenticated: "Chats"
default: "Connecting..."
}
Text(title)
ToolbarStatusLabel(title: "Updating...")
} else if state == .authenticated {
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.contentTransition(.numericText())
.animation(.easeInOut(duration: 0.25), value: state)
} else {
ToolbarStatusLabel(title: "Connecting...")
}
}
}
/// Desktop parity: "Updating..." with bouncing dots animation during sync.
private struct UpdatingDotsView: View {
@State private var activeDot = 0
private let dotCount = 3
private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
/// Desktop parity: circular spinner + status text (Mantine `<Loader size={12}>` equivalent).
private struct ToolbarStatusLabel: View {
let title: String
@State private var isSpinning = false
var body: some View {
HStack(spacing: 1) {
Text("Updating")
HStack(spacing: 5) {
Circle()
.trim(from: 0.05, to: 0.75)
.stroke(RosettaColors.Adaptive.text, style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
.frame(width: 12, height: 12)
.rotationEffect(.degrees(isSpinning ? 360 : 0))
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: isSpinning)
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
HStack(spacing: 2) {
ForEach(0..<dotCount, id: \.self) { index in
Text(".")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.offset(y: activeDot == index ? -3 : 0)
.animation(
.easeInOut(duration: 0.3),
value: activeDot
)
}
}
}
.onReceive(timer) { _ in
activeDot = (activeDot + 1) % dotCount
}
.onAppear { isSpinning = true }
}
}
@@ -453,24 +448,91 @@ private struct SyncAwareEmptyState: View {
}
}
// MARK: - Device Verification Banners (observation-isolated)
// MARK: - Request Chats Row (Telegram Archive style)
/// Shown at the top of the chat list when there are incoming message requests.
/// Matches ChatRowView sizing: height 78, pl-10, pr-16, avatar 62px.
private struct RequestChatsRow: View {
let count: Int
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 0) {
// Avatar: solid blue circle with white icon (62px)
ZStack {
Circle()
.fill(RosettaColors.primaryBlue)
.frame(width: 62, height: 62)
Image(systemName: "tray.and.arrow.down")
.font(.system(size: 24, weight: .medium))
.foregroundStyle(.white)
}
.padding(.trailing, 10)
// Content section matches ChatRowView.contentSection layout
VStack(alignment: .leading, spacing: 0) {
// Title row
Text("Request Chats")
.font(.system(size: 17, weight: .medium))
.tracking(-0.43)
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
// Subtitle row (count)
Text(count == 1 ? "1 request" : "\(count) requests")
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 63, alignment: .top)
.padding(.top, 8)
}
.padding(.leading, 10)
.padding(.trailing, 16)
.frame(height: 78)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Device Verification Content Router (observation-isolated)
/// Reads `ProtocolManager` in its own observation scope.
/// During handshake, `connectionState` changes 4+ times rapidly this view
/// absorbs those re-renders instead of cascading them to the NavigationStack.
private struct DeviceVerificationBannersContainer: View {
///
/// Device confirmation (THIS device waiting) is handled by full-screen overlay
/// in MainTabView (DeviceConfirmOverlay). This router only handles the
/// approval banner (ANOTHER device requesting access on primary device).
private struct DeviceVerificationContentRouter: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in }
var body: some View {
let proto = ProtocolManager.shared
if proto.connectionState == .deviceVerificationRequired {
DeviceWaitingApprovalBanner()
}
VStack(spacing: 0) {
// Banner for approving ANOTHER device (primary device side)
if let pendingDevice = proto.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
onDecline: { proto.declineDevice(pendingDevice.deviceId) }
)
}
if let pendingDevice = proto.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
onDecline: { proto.declineDevice(pendingDevice.deviceId) }
ChatListDialogContent(
viewModel: viewModel,
navigationState: navigationState,
onShowRequests: onShowRequests,
onPinnedStateChange: onPinnedStateChange
)
}
}
@@ -483,27 +545,39 @@ private struct DeviceVerificationBannersContainer: View {
private struct ChatListDialogContent: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in }
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: Set<String> = []
var body: some View {
let hasPinned = !viewModel.pinnedDialogs.isEmpty
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
SyncAwareEmptyState()
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
.onAppear { onPinnedStateChange(hasPinned) }
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
} else {
dialogList
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
.onAppear { onPinnedStateChange(hasPinned) }
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
// Compute once avoids 3× filter (allModeDialogs allModePinned allModeUnpinned).
let allDialogs = viewModel.allModeDialogs
let pinned = allDialogs.filter(\.isPinned)
let unpinned = allDialogs.filter { !$0.isPinned }
let requestsCount = viewModel.requestsCount
Group {
if allDialogs.isEmpty && !viewModel.isLoading {
SyncAwareEmptyState()
} else {
dialogList(
pinned: pinned,
unpinned: unpinned,
requestsCount: requestsCount
)
}
}
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
.onAppear {
onPinnedStateChange(!pinned.isEmpty)
}
}
private var dialogList: some View {
// MARK: - Dialog List
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
List {
if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in
@@ -513,15 +587,25 @@ private struct ChatListDialogContent: View {
.listRowSeparator(.hidden)
}
} else {
if !viewModel.pinnedDialogs.isEmpty {
ForEach(Array(viewModel.pinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
chatRow(dialog, isFirst: index == 0)
// Telegram-style "Request Chats" row at top (like Archived Chats)
if requestsCount > 0 {
RequestChatsRow(count: requestsCount, onTap: onShowRequests)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.visible, edges: .bottom)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
}
if !pinned.isEmpty {
ForEach(pinned, id: \.id) { dialog in
chatRow(dialog, isFirst: dialog.id == pinned.first?.id && requestsCount == 0)
.environment(\.rowBackgroundColor, RosettaColors.Dark.pinnedSectionBackground)
.listRowBackground(RosettaColors.Dark.pinnedSectionBackground)
}
}
ForEach(Array(viewModel.unpinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
chatRow(dialog, isFirst: index == 0 && viewModel.pinnedDialogs.isEmpty)
ForEach(unpinned, id: \.id) { dialog in
chatRow(dialog, isFirst: dialog.id == unpinned.first?.id && pinned.isEmpty && requestsCount == 0)
}
}
@@ -533,19 +617,20 @@ private struct ChatListDialogContent: View {
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.immediately)
.scrollIndicators(.hidden)
}
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
/// Desktop parity: wrap in SyncAwareChatRow to isolate @Observable read
/// of SessionManager.syncBatchInProgress from this view's observation scope.
/// viewModel + navigationState passed as plain `let` (not @ObservedObject)
/// stable class references don't trigger row re-evaluation on parent re-render.
SyncAwareChatRow(
dialog: dialog,
isTyping: typingDialogs.contains(dialog.opponentKey),
isFirst: isFirst,
onTap: { navigationState.path.append(ChatRoute(dialog: dialog)) },
onDelete: { withAnimation { viewModel.deleteDialog(dialog) } },
onToggleMute: { viewModel.toggleMute(dialog) },
onTogglePin: { viewModel.togglePin(dialog) }
viewModel: viewModel,
navigationState: navigationState
)
}
}
@@ -555,18 +640,28 @@ private struct ChatListDialogContent: View {
/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
/// observation scope. Without this wrapper, every sync state change would
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
private struct SyncAwareChatRow: View {
/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
/// observation scope. Without this wrapper, every sync state change would
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
///
/// **Performance:** `viewModel` and `navigationState` are stored as plain `let`
/// (not @ObservedObject). Class references compare by pointer in SwiftUI's
/// memcmp-based view diffing stable pointers mean unchanged rows are NOT
/// re-evaluated when the parent body rebuilds. Closures are defined inline
/// (not passed from parent) to avoid non-diffable closure props that force
/// every row dirty on every parent re-render.
struct SyncAwareChatRow: View {
let dialog: Dialog
let isTyping: Bool
let isFirst: Bool
let onTap: () -> Void
let onDelete: () -> Void
let onToggleMute: () -> Void
let onTogglePin: () -> Void
let viewModel: ChatListViewModel
let navigationState: ChatListNavigationState
var body: some View {
let isSyncing = SessionManager.shared.syncBatchInProgress
Button(action: onTap) {
Button {
navigationState.path.append(ChatRoute(dialog: dialog))
} label: {
ChatRowView(
dialog: dialog,
isSyncing: isSyncing,
@@ -575,62 +670,41 @@ private struct SyncAwareChatRow: View {
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
.listRowSeparator(isFirst ? .hidden : .visible, edges: .top)
.listRowSeparator(.visible, edges: .bottom)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive, action: onDelete) {
Label("Delete", systemImage: "trash")
}
if !dialog.isSavedMessages {
Button(action: onToggleMute) {
Label(
dialog.isMuted ? "Unmute" : "Mute",
systemImage: dialog.isMuted ? "bell" : "bell.slash"
)
}
.tint(dialog.isMuted ? .green : .indigo)
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button(action: onTogglePin) {
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
}
.tint(.orange)
}
}
}
// MARK: - Device Waiting Approval Banner
/// Shown when THIS device needs approval from another Rosetta device.
private struct DeviceWaitingApprovalBanner: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: "lock.shield")
.font(.system(size: 22))
.foregroundStyle(RosettaColors.warning)
VStack(alignment: .leading, spacing: 2) {
Text("Waiting for device approval")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Open Rosetta on your other device and approve this login.")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.listRowSeparator(isFirst ? .hidden : .visible, edges: .top)
.listRowSeparator(.visible, edges: .bottom)
.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")
}
Spacer(minLength: 0)
if !dialog.isSavedMessages {
Button {
viewModel.toggleMute(dialog)
} label: {
Label(
dialog.isMuted ? "Unmute" : "Mute",
systemImage: dialog.isMuted ? "bell" : "bell.slash"
)
}
.tint(dialog.isMuted ? .green : .indigo)
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
viewModel.togglePin(dialog)
} label: {
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
}
.tint(.orange)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(RosettaColors.warning.opacity(0.12))
}
}
// MARK: - Device Approval Banner
/// Shown on primary device when another device is requesting access.

View File

@@ -2,6 +2,11 @@ import Combine
import Foundation
import os
// MARK: - Dialogs Mode (All vs Requests)
/// Desktop parity: dialogs are split into "All" (iHaveSent) and "Requests" (only incoming).
enum DialogsMode: Hashable { case all, requests }
// MARK: - ChatListViewModel
@MainActor
@@ -12,6 +17,7 @@ final class ChatListViewModel: ObservableObject {
// MARK: - State
@Published var isLoading = false
@Published var dialogsMode: DialogsMode = .all
/// NOT @Published avoids 2× body re-renders per keystroke in ChatListView.
/// Local filtering uses `searchText` param directly in ChatListSearchContent.
var searchQuery = ""
@@ -35,16 +41,35 @@ final class ChatListViewModel: ObservableObject {
// 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.
/// Filtered dialog list based on `dialogsMode`.
/// - `all`: dialogs where I have sent (+ Saved Messages + system accounts)
/// - `requests`: dialogs where only opponent has messaged me
var filteredDialogs: [Dialog] {
DialogRepository.shared.sortedDialogs
let all = DialogRepository.shared.sortedDialogs
switch dialogsMode {
case .all:
return all.filter {
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
}
case .requests:
return all.filter {
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
}
}
}
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
/// Number of request dialogs (incoming-only, not system, not self-chat).
var requestsCount: Int {
DialogRepository.shared.sortedDialogs.filter {
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
}.count
}
var hasRequests: Bool { requestsCount > 0 }
var totalUnreadCount: Int {
DialogRepository.shared.dialogs.values
.lazy.filter { !$0.isMuted }
@@ -53,6 +78,27 @@ final class ChatListViewModel: ObservableObject {
var hasUnread: Bool { totalUnreadCount > 0 }
// MARK: - Per-mode dialogs (for TabView pages)
/// "All" dialogs conversations where I have sent (+ Saved Messages + system accounts).
/// Used by the All page in the swipeable TabView.
var allModeDialogs: [Dialog] {
DialogRepository.shared.sortedDialogs.filter {
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
}
}
var allModePinned: [Dialog] { allModeDialogs.filter(\.isPinned) }
var allModeUnpinned: [Dialog] { allModeDialogs.filter { !$0.isPinned } }
/// "Requests" dialogs conversations where only opponent has messaged me.
/// Used by the Requests page in the swipeable TabView.
var requestsModeDialogs: [Dialog] {
DialogRepository.shared.sortedDialogs.filter {
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
}
}
// MARK: - Actions
func setSearchQuery(_ query: String) {

View File

@@ -25,10 +25,6 @@ struct ChatRowView: View {
/// Desktop parity: show "typing..." instead of last message.
var isTyping: Bool = false
/// Desktop parity: recheck delivery timeout every 40s so clock error
/// transitions happen automatically without user scrolling.
@State private var now = Date()
private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect()
var displayTitle: String {
if dialog.isSavedMessages { return "Saved Messages" }
@@ -48,7 +44,6 @@ struct ChatRowView: View {
.padding(.trailing, 16)
.frame(height: 78)
.contentShape(Rectangle())
.onReceive(recheckTimer) { now = $0 }
}
}
@@ -107,7 +102,7 @@ private extension ChatRowView {
if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
VerifiedBadge(
verified: dialog.effectiveVerified,
size: 12
size: 16
)
}
@@ -145,8 +140,9 @@ private extension ChatRowView {
if dialog.lastMessage.isEmpty {
return "No messages yet"
}
// Strip inline markdown markers for clean chat list preview
return dialog.lastMessage.replacingOccurrences(of: "**", with: "")
// Strip inline markdown markers and convert emoji shortcodes for clean preview.
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
return EmojiParser.replaceShortcodes(in: cleaned)
}
}
@@ -198,38 +194,20 @@ 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:
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)
}
// Timer isolated to sub-view only .waiting rows create a timer.
DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
case .delivered:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.figmaBlue)
SingleCheckmarkShape()
.fill(RosettaColors.Adaptive.textSecondary)
.frame(width: 14, height: 10.3)
case .read:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.figmaBlue)
.overlay(alignment: .leading) {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.figmaBlue)
.offset(x: -4)
}
.padding(.trailing, 2)
DoubleCheckmarkShape()
.fill(RosettaColors.figmaBlue)
.frame(width: 17, height: 9.3)
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
@@ -237,12 +215,6 @@ private extension ChatRowView {
}
}
private var isWithinWaitingWindow: Bool {
guard dialog.lastMessageTimestamp > 0 else { return true }
let sentDate = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
return now.timeIntervalSince(sentDate) < Self.maxWaitingSeconds
}
var unreadBadge: some View {
let count = dialog.unreadCount
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
@@ -266,6 +238,37 @@ private extension ChatRowView {
}
}
// MARK: - Delivery Waiting Icon (timer-isolated)
/// Desktop parity: clock error after 80s. Timer only exists on rows with
/// `.waiting` delivery status all other rows have zero timer overhead.
private struct DeliveryWaitingIcon: View {
let sentTimestamp: Int64
@State private var now = Date()
private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect()
private var isWithinWindow: Bool {
guard sentTimestamp > 0 else { return true }
let sentDate = Date(timeIntervalSince1970: Double(sentTimestamp) / 1000)
return now.timeIntervalSince(sentDate) < 80
}
var body: some View {
Group {
if isWithinWindow {
Image(systemName: "clock")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
} else {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
}
}
.onReceive(recheckTimer) { now = $0 }
}
}
// MARK: - Time Formatting
private extension ChatRowView {

View File

@@ -0,0 +1,101 @@
import Lottie
import SwiftUI
/// Full-screen device confirmation overlay shown when THIS device
/// needs approval from another Rosetta device (desktop parity: DeviceConfirm.tsx).
///
/// Displayed as an overlay in MainTabView, covering nav bar, search, and tab bar.
struct DeviceConfirmView: View {
private let deviceName = UIDevice.current.name
var body: some View {
ZStack {
// Full black background covering everything
RosettaColors.Dark.background
.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
// Inbox animation (desktop parity: inbox.json)
LottieView(
animationName: "inbox",
loopMode: .loop,
animationSpeed: 1.0
)
.frame(width: 140, height: 140)
Spacer().frame(height: 24)
// Title (desktop: fw:500, fz:18)
Text("Confirm new device")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
Spacer().frame(height: 10)
// Description (desktop: fz:14, dimmed, centered, px:lg)
Text("To confirm this device, please check your first device attached to your account and approve the new device.")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 320)
.padding(.horizontal, 16)
Spacer().frame(height: 24)
// Exit button (desktop: animated red gradient, fullWidth, radius xl)
Button(action: exitAccount) {
Text("Exit")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(DeviceConfirmExitButtonStyle())
.padding(.horizontal, 32)
Spacer()
// Footer with device name (desktop: fz:12, dimmed, bold device name)
Text("Confirm device **\(deviceName)** on your first device to loading your chats.")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.6))
.multilineTextAlignment(.center)
.frame(maxWidth: 300)
.padding(.bottom, 40)
}
}
}
private func exitAccount() {
ProtocolManager.shared.disconnect()
}
}
// MARK: - Exit Button Style (red glass capsule, desktop: animated gradient #e03131 #ff5656)
private struct DeviceConfirmExitButtonStyle: ButtonStyle {
private let fillColor = RosettaColors.error
func makeBody(configuration: Configuration) -> some View {
Group {
if #available(iOS 26, *) {
configuration.label
.background {
Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85))
}
.glassEffect(.regular, in: Capsule())
} else {
configuration.label
.background {
Capsule()
.fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85))
}
.clipShape(Capsule())
}
}
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
}
}

View File

@@ -0,0 +1,124 @@
import Lottie
import SwiftUI
/// Screen showing incoming message requests opened from the "Request Chats"
/// row at the top of the main chat list (Telegram Archive style).
struct RequestChatsView: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
@Environment(\.dismiss) private var dismiss
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: Set<String> = []
var body: some View {
Group {
if viewModel.requestsModeDialogs.isEmpty {
RequestsEmptyStateView()
} else {
List {
ForEach(Array(viewModel.requestsModeDialogs.enumerated()), id: \.element.id) { index, dialog in
requestRow(dialog, isFirst: index == 0)
}
Color.clear.frame(height: 80)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.hidden)
}
}
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: {
backCapsuleLabel
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .principal) {
Text("Request Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
.modifier(ChatListToolbarBackgroundModifier())
.enableSwipeBack()
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
}
// MARK: - Capsule Back Button (matches ChatDetailView)
private var backCapsuleLabel: some View {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: .white
)
.frame(width: 11, height: 20)
.allowsHitTesting(false)
.frame(width: 36, height: 36)
.frame(height: 44)
.padding(.horizontal, 4)
.background {
glassCapsule(strokeOpacity: 0.22, strokeColor: .white)
}
}
@ViewBuilder
private func glassCapsule(strokeOpacity: Double = 0.18, strokeColor: Color = .white) -> some View {
if #available(iOS 26.0, *) {
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
} else {
Capsule().fill(.thinMaterial)
.overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
}
}
private func requestRow(_ dialog: Dialog, isFirst: Bool) -> some View {
SyncAwareChatRow(
dialog: dialog,
isTyping: typingDialogs.contains(dialog.opponentKey),
isFirst: isFirst,
viewModel: viewModel,
navigationState: navigationState
)
}
}
// MARK: - Requests Empty State
/// Shown when there are no incoming requests.
/// Design: folder Lottie + title + subtitle.
private struct RequestsEmptyStateView: View {
var body: some View {
VStack(spacing: 0) {
LottieView(animationName: "folder_empty", loopMode: .playOnce, animationSpeed: 1.0)
.frame(width: 150, height: 150)
Spacer().frame(height: 24)
Text("No Requests")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
Spacer().frame(height: 8)
Text("New message requests will appear here")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: -40)
}
}