Кросс-платформенное шифрование фото/аватаров, профиль собеседника, вложения в чате
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
101
Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift
Normal file
101
Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
124
Rosetta/Features/Chats/ChatList/RequestChatsView.swift
Normal file
124
Rosetta/Features/Chats/ChatList/RequestChatsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user