Выравнивание аватарок и онлайн-индикатора iOS с desktop (Mantine v8)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
Rosetta/Core/Utils/ProtocolConstants.swift
Normal file
52
Rosetta/Core/Utils/ProtocolConstants.swift
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user