diff --git a/.gitignore b/.gitignore index 3716042..330d7e8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ rosetta-android/ sprints/ CLAUDE.md +.claude.local.md +desktop # Xcode build/ diff --git a/Rosetta/Core/Data/Models/Dialog.swift b/Rosetta/Core/Data/Models/Dialog.swift index 6a17aad..ec3f4d0 100644 --- a/Rosetta/Core/Data/Models/Dialog.swift +++ b/Rosetta/Core/Data/Models/Dialog.swift @@ -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 { diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 5a4c2e3..fdb4b19 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -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() diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 9a2ef99..c3e6b5a 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -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 = [] @@ -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 + } } } diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 65fc377..4f889be 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -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") } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index d85da30..c102cf4 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -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] = [:] 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() + } + } + } } diff --git a/Rosetta/Core/Utils/ProtocolConstants.swift b/Rosetta/Core/Utils/ProtocolConstants.swift new file mode 100644 index 0000000..7e17b3a --- /dev/null +++ b/Rosetta/Core/Utils/ProtocolConstants.swift @@ -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 +} diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index 593b788..499e2a7 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -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) } diff --git a/Rosetta/DesignSystem/Components/AvatarView.swift b/Rosetta/DesignSystem/Components/AvatarView.swift index 26dcdc9..4be9add 100644 --- a/Rosetta/DesignSystem/Components/AvatarView.swift +++ b/Rosetta/DesignSystem/Components/AvatarView.swift @@ -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) diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift index 29af799..7aec1a6 100644 --- a/Rosetta/Features/Auth/UnlockView.swift +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -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"). diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index a284f5c..d43f6db 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -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)) + } } } diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index db51350..fd1b7dc 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -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) diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 9602de3..49e31d9 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -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) } } } diff --git a/Rosetta/Features/Chats/Search/SearchResultsSection.swift b/Rosetta/Features/Chats/Search/SearchResultsSection.swift index 1b86023..b05e241 100644 --- a/Rosetta/Features/Chats/Search/SearchResultsSection.swift +++ b/Rosetta/Features/Chats/Search/SearchResultsSection.swift @@ -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) diff --git a/Rosetta/Features/Chats/Search/SearchView.swift b/Rosetta/Features/Chats/Search/SearchView.swift index 3d6f73f..a7b8948 100644 --- a/Rosetta/Features/Chats/Search/SearchView.swift +++ b/Rosetta/Features/Chats/Search/SearchView.swift @@ -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)) diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 88db248..a3954e6 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -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) } } diff --git a/Rosetta/Features/Settings/SettingsViewModel.swift b/Rosetta/Features/Settings/SettingsViewModel.swift index 2ece0b6..99fd95a 100644 --- a/Rosetta/Features/Settings/SettingsViewModel.swift +++ b/Rosetta/Features/Settings/SettingsViewModel.swift @@ -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`.