Выравнивание аватарок и онлайн-индикатора iOS с desktop (Mantine v8)

This commit is contained in:
2026-03-08 17:10:02 +05:00
parent 196765f038
commit 8e27542c5b
17 changed files with 331 additions and 76 deletions

View File

@@ -69,7 +69,7 @@ struct Dialog: Identifiable, Codable, Equatable {
}
var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: opponentKey)
RosettaColors.avatarColorIndex(for: opponentTitle, publicKey: opponentKey)
}
var initials: String {

View File

@@ -111,6 +111,9 @@ final class DialogRepository {
dialogs[opponentKey] = dialog
schedulePersist()
// Desktop parity: re-evaluate request status based on last N messages.
updateRequestStatus(opponentKey: opponentKey)
}
func ensureDialog(
@@ -219,8 +222,52 @@ final class DialogRepository {
schedulePersist()
}
/// Desktop parity: check last N messages to determine if dialog should be a request.
/// If none of the last `dialogDropToRequestsMessageCount` messages are from me,
/// and the dialog is not a system account, mark as request (`iHaveSent = false`).
func updateRequestStatus(opponentKey: String) {
guard var dialog = dialogs[opponentKey] else { return }
// System accounts are never requests.
if SystemAccounts.isSystemAccount(opponentKey) {
if !dialog.iHaveSent {
dialog.iHaveSent = true
dialogs[opponentKey] = dialog
schedulePersist()
}
return
}
let messages = MessageRepository.shared.messages(for: opponentKey)
let recentMessages = messages.suffix(ProtocolConstants.dialogDropToRequestsMessageCount)
let hasMyMessage = recentMessages.contains { $0.fromPublicKey == currentAccount }
if dialog.iHaveSent != hasMyMessage {
dialog.iHaveSent = hasMyMessage
dialogs[opponentKey] = dialog
schedulePersist()
}
}
/// Desktop parity: remove dialog if it has no messages.
func removeDialogIfEmpty(opponentKey: String) {
let messages = MessageRepository.shared.messages(for: opponentKey)
if messages.isEmpty {
dialogs.removeValue(forKey: opponentKey)
schedulePersist()
}
}
func togglePin(opponentKey: String) {
guard var dialog = dialogs[opponentKey] else { return }
if !dialog.isPinned {
// Desktop parity: max 3 pinned dialogs.
let pinnedCount = dialogs.values.filter(\.isPinned).count
if pinnedCount >= ProtocolConstants.maxPinnedDialogs {
return
}
}
dialog.isPinned.toggle()
dialogs[opponentKey] = dialog
schedulePersist()

View File

@@ -5,8 +5,8 @@ import Combine
@MainActor
final class MessageRepository: ObservableObject {
static let shared = MessageRepository()
// Android keeps full history in DB; keep a much larger in-memory cap to avoid visible message loss.
private let maxMessagesPerDialog = 5_000
// Desktop parity: MESSAGE_MAX_LOADED = 40 per dialog.
private let maxMessagesPerDialog = ProtocolConstants.messageMaxCached
@Published private var messagesByDialog: [String: [ChatMessage]] = [:]
@Published private var typingDialogs: Set<String> = []
@@ -135,7 +135,7 @@ final class MessageRepository: ObservableObject {
}
}
func updateDeliveryStatus(messageId: String, status: DeliveryStatus) {
func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) {
guard let dialogKey = messageToDialog[messageId] else { return }
updateMessages(for: dialogKey) { messages in
guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return }
@@ -150,6 +150,10 @@ final class MessageRepository: ObservableObject {
return
}
messages[index].deliveryStatus = status
// Desktop parity: update timestamp on delivery ACK.
if let newTimestamp {
messages[index].timestamp = newTimestamp
}
}
}

View File

@@ -328,14 +328,15 @@ final class ProtocolManager: @unchecked Sendable {
private func startHeartbeat(interval: Int) {
heartbeatTask?.cancel()
let intervalMs = UInt64(interval) * 1_000_000_000 / 3
// Desktop parity: heartbeat at half the server-specified interval.
let intervalNs = UInt64(interval) * 1_000_000_000 / 2
heartbeatTask = Task {
// Send first heartbeat immediately
client.sendText("heartbeat")
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: intervalMs)
try? await Task.sleep(nanoseconds: intervalNs)
guard !Task.isCancelled else { break }
client.sendText("heartbeat")
}

View File

@@ -1,6 +1,7 @@
import Foundation
import Observation
import os
import UIKit
/// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle.
@Observable
@@ -34,14 +35,38 @@ final class SessionManager {
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
private var pendingOutgoingPackets: [String: PacketMessage] = [:]
private var pendingOutgoingAttempts: [String: Int] = [:]
private let maxOutgoingRetryAttempts = 3
private let maxOutgoingWaitingLifetimeMs: Int64 = 80_000
private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
// MARK: - Idle Detection (Desktop parity)
/// Tracks the last user interaction timestamp for idle detection.
/// Desktop: messages marked unread if user idle > 20 seconds.
private var lastUserInteractionTime: Date = Date()
private var idleObserverToken: NSObjectProtocol?
/// Whether the user is considered idle (no interaction for `idleTimeoutForUnreadS`).
private var isUserIdle: Bool {
Date().timeIntervalSince(lastUserInteractionTime) > ProtocolConstants.idleTimeoutForUnreadS
}
/// Whether the app is in the foreground.
private var isAppInForeground: Bool {
UIApplication.shared.applicationState == .active
}
private var userInfoSearchHandlerToken: UUID?
private init() {
setupProtocolCallbacks()
setupUserInfoSearchHandler()
setupIdleDetection()
}
/// Desktop parity: track user interaction to implement idle detection.
/// Call this from any user-facing interaction (tap, scroll, keyboard).
func recordUserInteraction() {
lastUserInteractionTime = Date()
}
// MARK: - Session Lifecycle
@@ -173,6 +198,7 @@ final class SessionManager {
pendingOutgoingRetryTasks.removeAll()
pendingOutgoingPackets.removeAll()
pendingOutgoingAttempts.removeAll()
lastUserInteractionTime = Date()
isAuthenticated = false
currentPublicKey = ""
displayName = ""
@@ -205,9 +231,12 @@ final class SessionManager {
status: .delivered
)
}
// Desktop parity: update both status AND timestamp on delivery ACK.
let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
MessageRepository.shared.updateDeliveryStatus(
messageId: packet.messageId,
status: .delivered
status: .delivered,
newTimestamp: deliveryTimestamp
)
self?.resolveOutgoingRetry(messageId: packet.messageId)
}
@@ -446,7 +475,11 @@ final class SessionManager {
ProtocolManager.shared.sendPacket(deliveryPacket)
}
if MessageRepository.shared.isDialogActive(opponentKey) {
// Desktop parity: only mark as read if user is NOT idle AND app is in foreground.
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
let shouldMarkRead = dialogIsActive && !isUserIdle && isAppInForeground
if shouldMarkRead {
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
MessageRepository.shared.markIncomingAsRead(
opponentKey: opponentKey,
@@ -839,4 +872,19 @@ final class SessionManager {
pendingOutgoingPackets.removeValue(forKey: messageId)
pendingOutgoingAttempts.removeValue(forKey: messageId)
}
// MARK: - Idle Detection Setup
private func setupIdleDetection() {
// Track app going to background/foreground to reset idle state.
idleObserverToken = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.lastUserInteractionTime = Date()
}
}
}
}

View File

@@ -0,0 +1,52 @@
import Foundation
/// Centralized protocol constants matching the Desktop reference implementation.
enum ProtocolConstants {
/// Auto-reconnect delay in seconds.
static let reconnectIntervalS: TimeInterval = 5
/// Number of messages loaded per batch (scroll-to-top pagination).
static let maxMessagesLoad = 20
/// Maximum messages kept in memory per dialog.
static let messageMaxCached = 40
/// Outgoing message delivery timeout in seconds.
/// If a WAITING message is older than this, mark it as ERROR.
static let messageDeliveryTimeoutS: Int64 = 80
/// User idle timeout for marking incoming messages as unread (seconds).
/// Desktop: `TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD = 20`.
static let idleTimeoutForUnreadS: TimeInterval = 20
/// Maximum number of file attachments per message.
static let maxAttachmentsInMessage = 5
/// Number of recent messages checked to determine `is_request` status.
/// Desktop: `DIALOG_DROP_TO_REQUESTS_IF_NO_MESSAGES_FROM_ME_COUNT = 30`.
static let dialogDropToRequestsMessageCount = 30
/// Minimum time between avatar rendering in message list (seconds).
static let avatarNoRenderTimeDiffS = 300
/// Maximum upload file size in megabytes.
static let maxUploadFilesizeMB = 1024
/// Maximum number of pinned dialogs per account.
static let maxPinnedDialogs = 3
/// Outgoing message retry delay in seconds.
static let outgoingRetryDelayS: TimeInterval = 4
/// Maximum number of outgoing message retry attempts.
static let maxOutgoingRetryAttempts = 3
/// Read receipt throttle interval in milliseconds.
static let readReceiptThrottleMs: Int64 = 400
/// Typing indicator throttle interval in milliseconds.
static let typingThrottleMs: Int64 = 2_000
/// Typing indicator display timeout in seconds.
static let typingDisplayTimeoutS: TimeInterval = 3
}

View File

@@ -114,30 +114,38 @@ enum RosettaColors {
Color(hex: 0xF7DC6F),
]
// MARK: Avatar Palette (11 colors, matching rosetta-android dark theme)
// MARK: Avatar Palette (Mantine v8 default, 11 colors)
// tint = shade-6 (used at 15% opacity for dark bg, 10% for light bg)
// text = shade-3 (dark mode text), shade-6 reused for light mode text
static let avatarColors: [(background: Color, text: Color)] = [
(Color(hex: 0x2D3548), Color(hex: 0x7DD3FC)), // blue
(Color(hex: 0x2D4248), Color(hex: 0x67E8F9)), // cyan
(Color(hex: 0x39334C), Color(hex: 0xD8B4FE)), // grape
(Color(hex: 0x2D3F32), Color(hex: 0x86EFAC)), // green
(Color(hex: 0x333448), Color(hex: 0xA5B4FC)), // indigo
(Color(hex: 0x383F2D), Color(hex: 0xBEF264)), // lime
(Color(hex: 0x483529), Color(hex: 0xFDBA74)), // orange
(Color(hex: 0x482D3D), Color(hex: 0xF9A8D4)), // pink
(Color(hex: 0x482D2D), Color(hex: 0xFCA5A5)), // red
(Color(hex: 0x2D4340), Color(hex: 0x5EEAD4)), // teal
(Color(hex: 0x3A334C), Color(hex: 0xC4B5FD)), // violet
static let avatarColors: [(tint: Color, text: Color)] = [
(Color(hex: 0x228be6), Color(hex: 0x74c0fc)), // blue
(Color(hex: 0x15aabf), Color(hex: 0x66d9e8)), // cyan
(Color(hex: 0xbe4bdb), Color(hex: 0xe599f7)), // grape
(Color(hex: 0x40c057), Color(hex: 0x8ce99a)), // green
(Color(hex: 0x4c6ef5), Color(hex: 0x91a7ff)), // indigo
(Color(hex: 0x82c91e), Color(hex: 0xc0eb75)), // lime
(Color(hex: 0xfd7e14), Color(hex: 0xffc078)), // orange
(Color(hex: 0xe64980), Color(hex: 0xfaa2c1)), // pink
(Color(hex: 0xfa5252), Color(hex: 0xffa8a8)), // red
(Color(hex: 0x12b886), Color(hex: 0x63e6be)), // teal
(Color(hex: 0x7950f2), Color(hex: 0xb197fc)), // violet
]
static func avatarColorIndex(for key: String) -> Int {
/// Desktop parity: color is determined by the display name, NOT the public key.
/// Server sends first 7 chars of publicKey as the default title; use that as fallback.
static func avatarColorIndex(for name: String, publicKey: String = "") -> Int {
let trimmed = name.trimmingCharacters(in: .whitespaces)
let input = trimmed.isEmpty ? String(publicKey.prefix(7)) : trimmed
var hash: Int32 = 0
for char in key.unicodeScalars {
hash = hash &* 31 &+ Int32(truncatingIfNeeded: char.value)
for char in input.unicodeScalars {
// Desktop: hash = (hash << 5) - hash + char hash * 31 + char
hash = (hash &<< 5) &- hash &+ Int32(truncatingIfNeeded: char.value)
// Desktop: hash |= 0 force 32-bit signed (Int32 handles this natively)
}
let count = Int32(avatarColors.count)
var index = hash % count
if index < 0 { index += count }
var index = abs(hash) % count
if index < 0 { index += count } // guard for Int32.min edge case
return Int(index)
}

View File

@@ -9,21 +9,35 @@ struct AvatarView: View {
var isOnline: Bool = false
var isSavedMessages: Bool = false
private var backgroundColor: Color {
RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].background
@Environment(\.colorScheme) private var colorScheme
private var avatarPair: (tint: Color, text: Color) {
RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count]
}
/// Mantine "light" variant: shade-3 in dark, shade-6 (tint) in light.
private var textColor: Color {
RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].text
colorScheme == .dark ? avatarPair.text : avatarPair.tint
}
private var fontSize: CGFloat { size * 0.38 }
private var badgeSize: CGFloat { size * 0.31 }
/// Desktop parity: 12px dot on 50px avatar = 24%.
private var badgeSize: CGFloat { size * 0.24 }
/// Desktop parity: 2px border on 50px avatar = 4%.
private var badgeBorderWidth: CGFloat { max(size * 0.04, 1.5) }
/// Mantine dark body background (#1A1B1E).
private static let mantineDarkBody = Color(hex: 0x1A1B1E)
var body: some View {
ZStack {
Circle()
.fill(isSavedMessages ? RosettaColors.primaryBlue : backgroundColor)
if isSavedMessages {
Circle().fill(RosettaColors.primaryBlue)
} else {
// Mantine "light" variant: opaque base + semi-transparent tint
Circle().fill(colorScheme == .dark ? Self.mantineDarkBody : .white)
Circle().fill(avatarPair.tint.opacity(colorScheme == .dark ? 0.15 : 0.10))
}
if isSavedMessages {
Image(systemName: "bookmark.fill")
@@ -31,23 +45,28 @@ struct AvatarView: View {
.foregroundStyle(.white)
} else {
Text(initials)
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundStyle(textColor)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
}
.frame(width: size, height: size)
.overlay(alignment: .bottomLeading) {
.overlay(alignment: .bottomTrailing) {
if isOnline {
Circle()
.fill(Color(hex: 0x4CD964))
.fill(RosettaColors.primaryBlue)
.frame(width: badgeSize, height: badgeSize)
.overlay {
Circle()
.stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.05)
.stroke(
colorScheme == .dark
? Color(hex: 0x1A1B1E)
: Color.white,
lineWidth: badgeBorderWidth
)
}
.offset(x: -1, y: 1)
.offset(x: 1, y: -1)
}
}
.accessibilityLabel(isSavedMessages ? "Saved Messages" : initials)

View File

@@ -30,7 +30,7 @@ struct UnlockView: View {
}
private var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: publicKey)
RosettaColors.avatarColorIndex(for: "", publicKey: publicKey)
}
/// Short public key 7 characters like Android (e.g. "0325a4d").

View File

@@ -207,7 +207,7 @@ private extension ChatDetailView {
}
var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: route.publicKey)
RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
}
var incomingBubbleFill: Color {
@@ -573,21 +573,34 @@ private extension ChatDetailView {
strokeOpacity: Double = 0.18,
strokeColor: Color = RosettaColors.Adaptive.border
) -> some View {
let border = strokeColor.opacity(max(0.28, strokeOpacity))
switch shape {
case .capsule:
Capsule()
.fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
.overlay(Capsule().stroke(border, lineWidth: 0.8))
case .circle:
Circle()
.fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
.overlay(Circle().stroke(border, lineWidth: 0.8))
case let .rounded(radius):
let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous)
rounded
.fill(RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x1C1C1E)))
.overlay(rounded.stroke(border, lineWidth: 0.8))
if #available(iOS 26.0, *) {
switch shape {
case .capsule:
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
case .circle:
Circle().fill(.clear).glassEffect(.regular, in: .circle)
case let .rounded(radius):
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
}
} else {
let border = strokeColor.opacity(max(0.28, strokeOpacity))
switch shape {
case .capsule:
Capsule()
.fill(.ultraThinMaterial)
.overlay(Capsule().stroke(border, lineWidth: 0.8))
case .circle:
Circle()
.fill(.ultraThinMaterial)
.overlay(Circle().stroke(border, lineWidth: 0.8))
case let .rounded(radius):
let rounded = RoundedRectangle(cornerRadius: radius, style: .continuous)
rounded
.fill(.ultraThinMaterial)
.overlay(rounded.stroke(border, lineWidth: 0.8))
}
}
}

View File

@@ -151,7 +151,7 @@ private extension ChatListSearchContent {
let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey
)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button {
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
@@ -207,7 +207,7 @@ private extension ChatListSearchContent {
let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey
)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button {
viewModel.addToRecent(user)

View File

@@ -164,7 +164,7 @@ private struct ToolbarStoriesAvatar: View {
let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk
)
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
let colorIdx = RosettaColors.avatarColorIndex(for: SessionManager.shared.displayName, publicKey: pk)
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
}
}

View File

@@ -61,7 +61,7 @@ private extension SearchResultsSection {
func searchResultRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button {
onSelectUser(user)

View File

@@ -249,7 +249,7 @@ private struct RecentSection: View {
let currentPK = SessionManager.shared.currentPublicKey
let isSelf = user.publicKey == currentPK
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
return Button {
navigationPath.append(ChatRoute(recent: user))

View File

@@ -15,13 +15,48 @@ struct MainTabView: View {
@State private var dragFractionalIndex: CGFloat?
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🔴 MainTabView.body #\(Self._bodyCount) search=\(isChatSearchActive) chatDetail=\(isChatListDetailPresented) searchDetail=\(isSearchDetailPresented)")
mainTabView
Group {
if #available(iOS 26.0, *) {
systemTabView
} else {
legacyTabView
}
}
}
@MainActor static var _bodyCount = 0
private var mainTabView: some View {
// MARK: - iOS 26+ (native TabView with liquid glass tab bar)
@available(iOS 26.0, *)
private var systemTabView: some View {
TabView(selection: $selectedTab) {
ChatListView(
isSearchActive: $isChatSearchActive,
isDetailPresented: $isChatListDetailPresented
)
.tabItem {
Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon)
}
.tag(RosettaTab.chats)
.badgeIfNeeded(chatUnreadBadge)
SettingsView(onLogout: onLogout)
.tabItem {
Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon)
}
.tag(RosettaTab.settings)
SearchView(isDetailPresented: $isSearchDetailPresented)
.tabItem {
Label(RosettaTab.search.label, systemImage: RosettaTab.search.icon)
}
.tag(RosettaTab.search)
}
.tint(RosettaColors.primaryBlue)
}
// MARK: - iOS < 26 (custom RosettaTabBar with pager)
private var legacyTabView: some View {
ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background
.ignoresSafeArea()
@@ -35,7 +70,6 @@ struct MainTabView: View {
selectedTab: selectedTab,
onTabSelected: { tab in
activatedTabs.insert(tab)
// Activate adjacent tabs for smooth paging
for t in RosettaTab.interactionOrder { activatedTabs.insert(t) }
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
selectedTab = tab
@@ -43,7 +77,6 @@ struct MainTabView: View {
},
onSwipeStateChanged: { state in
if let state {
// Activate all main tabs during drag for smooth paging
for tab in RosettaTab.interactionOrder {
activatedTabs.insert(tab)
}
@@ -53,12 +86,18 @@ struct MainTabView: View {
dragFractionalIndex = nil
}
}
}
},
badges: tabBadges
)
.ignoresSafeArea(.keyboard)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.onChange(of: isChatSearchActive) { _, isActive in
if isActive {
dragFractionalIndex = nil
}
}
}
private var currentPageIndex: CGFloat {
@@ -70,8 +109,6 @@ struct MainTabView: View {
let width = max(1, availableSize.width)
let totalWidth = width * CGFloat(RosettaTab.interactionOrder.count)
// Child views are in a separate HStack that does NOT read dragFractionalIndex,
// so they won't re-render during drag only the offset modifier updates.
HStack(spacing: 0) {
ForEach(RosettaTab.interactionOrder, id: \.self) { tab in
tabView(for: tab)
@@ -110,6 +147,33 @@ struct MainTabView: View {
isChatListDetailPresented || isSearchDetailPresented
}
private var tabBadges: [TabBadge] {
guard let chatUnreadBadge else {
return []
}
return [TabBadge(tab: .chats, text: chatUnreadBadge)]
}
private var chatUnreadBadge: String? {
let unread = DialogRepository.shared.sortedDialogs
.filter { !$0.isMuted }
.reduce(0) { $0 + $1.unreadCount }
if unread <= 0 {
return nil
}
return unread > 999 ? "\(unread / 1000)K" : "\(unread)"
}
}
private extension View {
@ViewBuilder
func badgeIfNeeded(_ value: String?) -> some View {
if let value {
badge(value)
} else {
self
}
}
}
// MARK: - Pager Offset Modifier
@@ -120,11 +184,8 @@ private struct PagerOffsetModifier: ViewModifier {
let effectiveIndex: CGFloat
let pageWidth: CGFloat
let isDragging: Bool
@MainActor static var _bodyCount = 0
func body(content: Content) -> some View {
let _ = Self._bodyCount += 1
let _ = print("⬛ PagerOffset.body #\(Self._bodyCount) idx=\(effectiveIndex) w=\(pageWidth)")
content
.offset(x: -effectiveIndex * pageWidth)
.animation(
@@ -168,7 +229,7 @@ struct PlaceholderTabView: View {
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
}
}

View File

@@ -27,7 +27,7 @@ final class SettingsViewModel: ObservableObject {
}
var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: publicKey)
RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey)
}
/// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`.