Групповые чаты: sender name/avatar в ячейках, multi-typer typing, фикс скачивания фото/аватарок и verified badge

This commit is contained in:
2026-04-03 18:04:41 +05:00
parent de0818fe69
commit da6b3d7c3f
35 changed files with 2728 additions and 386 deletions

View File

@@ -32,6 +32,9 @@ struct ChatListView: View {
@State private var searchText = ""
@State private var hasPinnedChats = false
@State private var showRequestChats = false
@State private var showNewGroupSheet = false
@State private var showJoinGroupSheet = false
@State private var showNewChatActionSheet = false
@FocusState private var isSearchFocused: Bool
var body: some View {
@@ -103,6 +106,35 @@ struct ChatListView: View {
}
}
.tint(RosettaColors.figmaBlue)
.confirmationDialog("New", isPresented: $showNewChatActionSheet) {
Button("New Group") { showNewGroupSheet = true }
Button("Join Group") { showJoinGroupSheet = true }
Button("Cancel", role: .cancel) {}
}
.sheet(isPresented: $showNewGroupSheet) {
NavigationStack {
GroupSetupView { route in
showNewGroupSheet = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
navigationState.path = [route]
}
}
}
.presentationDetents([.large])
.preferredColorScheme(.dark)
}
.sheet(isPresented: $showJoinGroupSheet) {
NavigationStack {
GroupJoinView { route in
showJoinGroupSheet = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
navigationState.path = [route]
}
}
}
.presentationDetents([.large])
.preferredColorScheme(.dark)
}
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
guard let route = notification.object as? ChatRoute else { return }
// Navigate to the chat from push notification tap (fast path)
@@ -307,7 +339,7 @@ private extension ChatListView {
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("Camera")
Button { } label: {
Button { showNewChatActionSheet = true } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
@@ -350,7 +382,7 @@ private extension ChatListView {
.buttonStyle(.plain)
.accessibilityLabel("Add chat")
Button { } label: {
Button { showNewChatActionSheet = true } label: {
Image("toolbar-compose")
.renderingMode(.template)
.resizable()
@@ -589,7 +621,7 @@ private struct ChatListDialogContent: View {
var onPinnedStateChange: (Bool) -> Void = { _ in }
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: Set<String> = []
@State private var typingDialogs: [String: Set<String>] = [:]
var body: some View {
let _ = PerformanceLogger.shared.track("chatList.bodyEval")
@@ -687,7 +719,14 @@ private struct ChatListDialogContent: View {
/// stable class references don't trigger row re-evaluation on parent re-render.
SyncAwareChatRow(
dialog: dialog,
isTyping: typingDialogs.contains(dialog.opponentKey),
isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true),
typingSenderNames: {
guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] }
return senderKeys.map { sk in
DialogRepository.shared.dialogs[sk]?.opponentTitle
?? String(sk.prefix(8))
}
}(),
isFirst: isFirst,
viewModel: viewModel,
navigationState: navigationState
@@ -713,6 +752,7 @@ private struct ChatListDialogContent: View {
struct SyncAwareChatRow: View {
let dialog: Dialog
let isTyping: Bool
let typingSenderNames: [String]
let isFirst: Bool
let viewModel: ChatListViewModel
let navigationState: ChatListNavigationState
@@ -725,7 +765,8 @@ struct SyncAwareChatRow: View {
ChatRowView(
dialog: dialog,
isSyncing: isSyncing,
isTyping: isTyping
isTyping: isTyping,
typingSenderNames: typingSenderNames
)
}
.buttonStyle(.plain)

View File

@@ -24,10 +24,19 @@ struct ChatRowView: View {
var isSyncing: Bool = false
/// Desktop parity: show "typing..." instead of last message.
var isTyping: Bool = false
/// Group typing: sender names for "Name typing..." / "Name and N typing..." display.
var typingSenderNames: [String] = []
var displayTitle: String {
if dialog.isSavedMessages { return "Saved Messages" }
if dialog.isGroup {
let meta = GroupRepository.shared.groupMetadata(
account: dialog.account,
groupDialogKey: dialog.opponentKey
)
if let title = meta?.title, !title.isEmpty { return title }
}
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
return String(dialog.opponentKey.prefix(12))
@@ -57,9 +66,17 @@ private struct ChatRowAvatar: View {
let dialog: Dialog
var body: some View {
if dialog.isGroup {
groupAvatarView
} else {
directAvatarView
}
}
private var directAvatarView: some View {
// Establish @Observable tracking re-renders this view on avatar save/remove.
let _ = AvatarRepository.shared.avatarVersion
AvatarView(
return AvatarView(
initials: dialog.initials,
colorIndex: dialog.avatarColorIndex,
size: 62,
@@ -68,6 +85,27 @@ private struct ChatRowAvatar: View {
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
)
}
private var groupAvatarView: some View {
let _ = AvatarRepository.shared.avatarVersion
let groupImage = AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
return ZStack {
if let image = groupImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 62, height: 62)
.clipShape(Circle())
} else {
Circle()
.fill(RosettaColors.avatarColors[dialog.avatarColorIndex % RosettaColors.avatarColors.count].tint)
.frame(width: 62, height: 62)
Image(systemName: "person.2.fill")
.font(.system(size: 24, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
}
}
}
}
private extension ChatRowView {
@@ -152,12 +190,23 @@ private extension ChatRowView {
var messageText: String {
// Desktop parity: show "typing..." in chat list row when opponent is typing.
if isTyping && !dialog.isSavedMessages {
if dialog.isGroup && !typingSenderNames.isEmpty {
if typingSenderNames.count == 1 {
return "\(typingSenderNames[0]) typing..."
} else {
return "\(typingSenderNames[0]) and \(typingSenderNames.count - 1) typing..."
}
}
return "typing..."
}
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty {
return "No messages yet"
}
// Desktop parity: show "Group invite" for #group: invite messages.
if raw.hasPrefix("#group:") {
return "Group invite"
}
// Safety net: never show encrypted ciphertext (ivBase64:ctBase64) to user.
// This catches stale data persisted before isGarbageText was improved.
if Self.looksLikeCiphertext(raw) {

View File

@@ -9,7 +9,7 @@ struct RequestChatsView: View {
@Environment(\.dismiss) private var dismiss
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: Set<String> = []
@State private var typingDialogs: [String: Set<String>] = [:]
var body: some View {
Group {
@@ -79,7 +79,14 @@ struct RequestChatsView: View {
private func requestRow(_ dialog: Dialog, isFirst: Bool) -> some View {
SyncAwareChatRow(
dialog: dialog,
isTyping: typingDialogs.contains(dialog.opponentKey),
isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true),
typingSenderNames: {
guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] }
return senderKeys.map { sk in
DialogRepository.shared.dialogs[sk]?.opponentTitle
?? String(sk.prefix(8))
}
}(),
isFirst: isFirst,
viewModel: viewModel,
navigationState: navigationState