Уведомления в фоне, оптимизация FPS чата, release notes, read receipts паритет с Android

This commit is contained in:
2026-03-18 20:10:20 +05:00
parent 1f442e1298
commit 422b20702e
42 changed files with 2459 additions and 656 deletions

View File

@@ -325,7 +325,8 @@ private struct FavoriteContactsRowSearch: View {
colorIndex: dialog.avatarColorIndex,
size: 62,
isOnline: dialog.isOnline,
isSavedMessages: dialog.isSavedMessages
isSavedMessages: dialog.isSavedMessages,
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
)
Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "")

View File

@@ -564,14 +564,13 @@ private struct ChatListDialogContent: View {
@State private var typingDialogs: Set<String> = []
var body: some View {
// Compute once avoids 3× filter (allModeDialogs allModePinned allModeUnpinned).
let allDialogs = viewModel.allModeDialogs
let pinned = allDialogs.filter(\.isPinned)
let unpinned = allDialogs.filter { !$0.isPinned }
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
let pinned = viewModel.allModePinned
let unpinned = viewModel.allModeUnpinned
let requestsCount = viewModel.requestsCount
Group {
if allDialogs.isEmpty && !viewModel.isLoading {
if pinned.isEmpty && unpinned.isEmpty && !viewModel.isLoading {
SyncAwareEmptyState()
} else {
dialogList(
@@ -719,52 +718,44 @@ struct SyncAwareChatRow: View {
// MARK: - Device Approval Banner
/// Shown on primary device when another device is requesting access.
/// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline.
/// Desktop: DeviceVerify.tsx height 65px, centered text (dimmed), two transparent buttons.
private struct DeviceApprovalBanner: View {
let device: DeviceEntry
let onAccept: () -> Void
let onDecline: () -> Void
@State private var showAcceptConfirmation = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.shield")
.font(.system(size: 22))
.foregroundStyle(RosettaColors.error)
VStack(spacing: 8) {
Text("New login from \(device.deviceName) (\(device.deviceOs))")
.font(.system(size: 13, weight: .regular))
.foregroundStyle(.white.opacity(0.45))
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 2) {
Text("New device login detected")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("\(device.deviceName) (\(device.deviceOs))")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
HStack(spacing: 24) {
Button("Accept") {
showAcceptConfirmation = true
}
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.primaryBlue)
Spacer(minLength: 0)
Button("Decline") {
onDecline()
}
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.error.opacity(0.8))
}
HStack(spacing: 12) {
Button(action: onAccept) {
Text("Yes, it's me")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.primaryBlue)
}
Button(action: onDecline) {
Text("No, it's not me!")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.error)
}
Spacer(minLength: 0)
}
.padding(.leading, 34)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(RosettaColors.error.opacity(0.08))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.alert("Accept new device", isPresented: $showAcceptConfirmation) {
Button("Accept") { onAccept() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to accept this device? This will allow it to access your account.")
}
}
}

View File

@@ -39,65 +39,70 @@ final class ChatListViewModel: ObservableObject {
}
// MARK: - Computed (dialog list for ChatListDialogContent)
// MARK: - Dialog partitions (single pass, cached per observation cycle)
private struct DialogPartition {
var allPinned: [Dialog] = []
var allUnpinned: [Dialog] = []
var requests: [Dialog] = []
var totalUnread: Int = 0
}
/// Cached partition computed once, reused by all properties until dialogs change.
private var _cachedPartition: DialogPartition?
private var _cachedPartitionVersion: Int = -1
private var partition: DialogPartition {
let repo = DialogRepository.shared
let currentVersion = repo.dialogsVersion
if let cached = _cachedPartition, _cachedPartitionVersion == currentVersion {
return cached
}
var result = DialogPartition()
for dialog in repo.sortedDialogs {
let isChat = dialog.iHaveSent || dialog.isSavedMessages || SystemAccounts.isSystemAccount(dialog.opponentKey)
if isChat {
if dialog.isPinned {
result.allPinned.append(dialog)
} else {
result.allUnpinned.append(dialog)
}
} else {
result.requests.append(dialog)
}
if !dialog.isMuted {
result.totalUnread += dialog.unreadCount
}
}
_cachedPartition = result
_cachedPartitionVersion = currentVersion
return result
}
/// 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] {
let all = DialogRepository.shared.sortedDialogs
let p = partition
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)
}
case .all: return p.allPinned + p.allUnpinned
case .requests: return p.requests
}
}
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 pinnedDialogs: [Dialog] { partition.allPinned }
var unpinnedDialogs: [Dialog] { partition.allUnpinned }
var requestsCount: Int { partition.requests.count }
var hasRequests: Bool { requestsCount > 0 }
var totalUnreadCount: Int {
DialogRepository.shared.dialogs.values
.lazy.filter { !$0.isMuted }
.reduce(0) { $0 + $1.unreadCount }
}
var totalUnreadCount: Int { partition.totalUnread }
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 allModeDialogs: [Dialog] { partition.allPinned + partition.allUnpinned }
var allModePinned: [Dialog] { partition.allPinned }
var allModeUnpinned: [Dialog] { partition.allUnpinned }
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)
}
}
var requestsModeDialogs: [Dialog] { partition.requests }
// MARK: - Actions

View File

@@ -57,7 +57,7 @@ private extension ChatRowView {
size: 62,
isOnline: dialog.isOnline,
isSavedMessages: dialog.isSavedMessages,
image: AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
)
}
}
@@ -132,6 +132,9 @@ private extension ChatRowView {
.frame(height: 41, alignment: .topLeading)
}
/// Static cache for emoji-parsed message text (avoids regex per row per render).
private static var messageTextCache: [String: String] = [:]
var messageText: String {
// Desktop parity: show "typing..." in chat list row when opponent is typing.
if isTyping && !dialog.isSavedMessages {
@@ -140,9 +143,18 @@ private extension ChatRowView {
if dialog.lastMessage.isEmpty {
return "No messages yet"
}
if let cached = Self.messageTextCache[dialog.lastMessage] {
return cached
}
// Strip inline markdown markers and convert emoji shortcodes for clean preview.
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
return EmojiParser.replaceShortcodes(in: cleaned)
let result = EmojiParser.replaceShortcodes(in: cleaned)
if Self.messageTextCache.count > 500 {
let keysToRemove = Array(Self.messageTextCache.keys.prefix(250))
for key in keysToRemove { Self.messageTextCache.removeValue(forKey: key) }
}
Self.messageTextCache[dialog.lastMessage] = result
return result
}
}
@@ -282,22 +294,37 @@ private extension ChatRowView {
let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f
}()
/// Static cache for formatted time strings (avoids Date/Calendar per row per render).
private static var timeStringCache: [Int64: String] = [:]
var formattedTime: String {
guard dialog.lastMessageTimestamp > 0 else { return "" }
if let cached = Self.timeStringCache[dialog.lastMessageTimestamp] {
return cached
}
let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
let now = Date()
let calendar = Calendar.current
let result: String
if calendar.isDateInToday(date) {
return Self.timeFormatter.string(from: date)
result = Self.timeFormatter.string(from: date)
} else if calendar.isDateInYesterday(date) {
return "Yesterday"
result = "Yesterday"
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 {
return Self.dayFormatter.string(from: date)
result = Self.dayFormatter.string(from: date)
} else {
return Self.dateFormatter.string(from: date)
result = Self.dateFormatter.string(from: date)
}
if Self.timeStringCache.count > 500 {
let keysToRemove = Array(Self.timeStringCache.keys.prefix(250))
for key in keysToRemove { Self.timeStringCache.removeValue(forKey: key) }
}
Self.timeStringCache[dialog.lastMessageTimestamp] = result
return result
}
}

View File

@@ -76,9 +76,7 @@ struct RequestChatsView: 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)
TelegramGlassCapsule()
}
}