Онлайн-статусы, исправление навигации и UI чатов

- Реализован PacketOnlineSubscribe (0x04) для подписки на статус собеседника
- Онлайн-статус загружается из результатов поиска (PacketSearch) при каждом хэндшейке
- Toolbar capsule показывает online/offline/typing вместо @username
- Зелёная точка онлайн-индикатора на аватаре в списке чатов (bottom-left, как в Android)
- Убрана точка с аватара в toolbar (статус отображается текстом)
- Исправлен баг двойного тапа при входе в чат (программная навигация вместо NavigationLink)
- DialogRepository.updateUserInfo теперь принимает и сохраняет online-статус
- Очистка requestedUserInfoKeys при реконнекте для обновления статусов
- Добавлено логирование результатов поиска и отправки пакетов

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 04:00:57 +05:00
parent 003c262378
commit 6bef51e235
16 changed files with 769 additions and 405 deletions

View File

@@ -67,10 +67,33 @@ struct ChatListView: View {
private extension ChatListView {
@ViewBuilder
var normalContent: some View {
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
dialogList
VStack(spacing: 0) {
deviceVerificationBanners
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
dialogList
}
}
}
@ViewBuilder
var deviceVerificationBanners: some View {
let protocol_ = ProtocolManager.shared
// Banner 1: THIS device needs approval from another device
if protocol_.connectionState == .deviceVerificationRequired {
DeviceWaitingApprovalBanner()
}
// Banner 2: ANOTHER device needs approval from THIS device
if let pendingDevice = protocol_.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { protocol_.acceptDevice(pendingDevice.deviceId) },
onDecline: { protocol_.declineDevice(pendingDevice.deviceId) }
)
}
}
@@ -106,11 +129,13 @@ private extension ChatListView {
}
func chatRow(_ dialog: Dialog) -> some View {
NavigationLink(value: ChatRoute(dialog: dialog)) {
Button {
navigationPath.append(ChatRoute(dialog: dialog))
} label: {
ChatRowView(dialog: dialog)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
.listRowInsets(EdgeInsets())
.listRowSeparator(.visible)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
@@ -199,4 +224,83 @@ private extension ChatListView {
}
}
// MARK: - Device Waiting Approval Banner
/// Shown when THIS device needs approval from another Rosetta device.
private struct DeviceWaitingApprovalBanner: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: "lock.shield")
.font(.system(size: 22))
.foregroundStyle(RosettaColors.warning)
VStack(alignment: .leading, spacing: 2) {
Text("Waiting for device approval")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Open Rosetta on your other device and approve this login.")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(RosettaColors.warning.opacity(0.12))
}
}
// MARK: - Device Approval Banner
/// Shown on primary device when another device is requesting access.
private struct DeviceApprovalBanner: View {
let device: DeviceEntry
let onAccept: () -> Void
let onDecline: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.shield")
.font(.system(size: 22))
.foregroundStyle(RosettaColors.error)
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)
}
Spacer(minLength: 0)
}
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))
}
}
#Preview { ChatListView(isSearchActive: .constant(false), onChatDetailVisibilityChange: nil) }

View File

@@ -18,6 +18,7 @@ final class ChatListViewModel: ObservableObject {
@Published var recentSearches: [RecentSearch] = []
private var searchTask: Task<Void, Never>?
private var searchRetryTask: Task<Void, Never>?
private var lastSearchedText = ""
private var searchHandlerToken: UUID?
private var recentSearchesCancellable: AnyCancellable?
@@ -107,7 +108,8 @@ final class ChatListViewModel: ObservableObject {
publicKey: user.publicKey,
title: user.title,
username: user.username,
verified: user.verified
verified: user.verified,
online: user.online
)
}
}
@@ -141,6 +143,10 @@ final class ChatListViewModel: ObservableObject {
guard connState == .authenticated, let hash else {
self.isServerSearching = false
// Reset so next attempt re-sends instead of being de-duped
self.lastSearchedText = ""
// Retry after 2 seconds if still have a query
self.scheduleSearchRetry()
return
}
@@ -153,6 +159,17 @@ final class ChatListViewModel: ObservableObject {
}
}
private func scheduleSearchRetry() {
searchRetryTask?.cancel()
searchRetryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
guard let self, !Task.isCancelled else { return }
let q = self.searchQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return }
self.triggerServerSearch()
}
}
private func normalizeSearchInput(_ input: String) -> String {
input.replacingOccurrences(of: "@", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -2,17 +2,31 @@ import SwiftUI
// MARK: - ChatRowView
/// Chat row matching Figma "Row - Chats" component spec:
/// Row: height 78, paddingLeft 10, paddingRight 16, vertical center
/// Avatar: 62px circle, 10pt trailing padding
/// Title: SF Pro Medium 17pt, tracking -0.43, primary color
/// Message: SF Pro Regular 15pt, tracking -0.23, secondary color
/// Time: SF Pro Regular 14pt, tracking -0.23, secondary color
/// Badges gap: 6pt verified 12px, muted 12px
/// Trailing: pt 8, pb 14 readStatus + time (gap 2), pin/count at bottom
/// Chat row matching Figma "Row - Chats" component spec (node 3994:38947):
///
/// Row: height 78, pl-10, pr-16, items-center
/// Avatar: 62px circle, pr-10
/// Contents: flex-col, h-full, items-start, justify-center, pb-px
/// Title and Trailing Accessories: flex-1, gap-6, items-center, w-full
/// Title and Detail: flex-1, h-63, items-start, overflow-clip
/// Title: gap-4, items-center SF Pro Medium 17/22, tracking -0.43
/// Message: h-41 SF Pro Regular 15/20, tracking -0.23, secondary
/// Accessories: h-full, items-center, justify-end
/// Contents-Trailing: flex-col, h-full, items-end, justify-between, pt-8
/// Time: SF Pro Regular 14/20, tracking -0.23, secondary
/// Other: flex-1, items-end, justify-end, pb-14
/// Badge: bg-#008BFF, min-w-20, max-w-37, px-4, rounded-full
/// SF Pro Regular 15/20, black, tracking -0.23
struct ChatRowView: View {
let dialog: Dialog
var displayTitle: String {
if dialog.isSavedMessages { return "Saved Messages" }
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
return String(dialog.opponentKey.prefix(12))
}
var body: some View {
HStack(spacing: 0) {
avatarSection
@@ -41,32 +55,38 @@ private extension ChatRowView {
}
}
// MARK: - Content Section (two-column: title+detail | trailing accessories)
// MARK: - Content Section
// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px
// "Title and Trailing Accessories": flex-1, gap-6, items-center
private extension ChatRowView {
var contentSection: some View {
HStack(alignment: .center, spacing: 6) {
// Left column: title + message
VStack(alignment: .leading, spacing: 2) {
// "Title and Detail": flex-1, h-63, items-start, overflow-clip
VStack(alignment: .leading, spacing: 0) {
titleRow
messageRow
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 63)
.clipped()
// Right column: time + pin/badge
// "Accessories and Grabber": h-full, items-center, justify-end
trailingColumn
.frame(maxHeight: .infinity)
}
.frame(height: 63)
.frame(maxHeight: .infinity)
.padding(.bottom, 1)
}
}
// MARK: - Title Row (name + badges)
// Figma "Title": gap-4, items-center, w-full
private extension ChatRowView {
var titleRow: some View {
HStack(spacing: 4) {
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
Text(displayTitle)
.font(.system(size: 17, weight: .medium))
.tracking(-0.43)
.foregroundStyle(RosettaColors.Adaptive.text)
@@ -89,6 +109,7 @@ private extension ChatRowView {
}
// MARK: - Message Row
// Figma "Message": h-41, SF Pro Regular 15/20, tracking -0.23, secondary
private extension ChatRowView {
var messageRow: some View {
@@ -96,7 +117,8 @@ private extension ChatRowView {
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
.lineLimit(2)
.frame(height: 41, alignment: .topLeading)
}
var messageText: String {
@@ -107,7 +129,10 @@ private extension ChatRowView {
}
}
// MARK: - Trailing Column (time + delivery on top, pin/badge on bottom)
// MARK: - Trailing Column
// Figma "Contents - Trailing": flex-col, h-full, items-end, justify-between, pt-8
// "Read Status and Time": gap-2, items-center
// "Other": flex-1, items-end, justify-end, pb-14
private extension ChatRowView {
var trailingColumn: some View {
@@ -127,7 +152,7 @@ private extension ChatRowView {
: RosettaColors.Adaptive.textSecondary
)
}
.padding(.top, 2)
.padding(.top, 8)
Spacer(minLength: 0)
@@ -144,7 +169,7 @@ private extension ChatRowView {
unreadBadge
}
}
.padding(.bottom, 2)
.padding(.bottom, 14)
}
}
@@ -181,14 +206,18 @@ private extension ChatRowView {
let count = dialog.unreadCount
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
let isMuted = dialog.isMuted
let isSmall = count < 10
return Text(text)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(.white)
.padding(.horizontal, 4)
.frame(minWidth: 20, minHeight: 20)
.frame(maxWidth: 37)
.foregroundStyle(.black)
.padding(.horizontal, isSmall ? 0 : 4)
.frame(
minWidth: 20,
maxWidth: isSmall ? 20 : 37,
minHeight: 20
)
.background {
Capsule()
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)