Уведомления в фоне, оптимизация FPS чата, release notes, read receipts паритет с Android
This commit is contained in:
@@ -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 ?? "")
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user