Групповые чаты: sender name/avatar в ячейках, multi-typer typing, фикс скачивания фото/аватарок и verified badge
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user