iOS update: ChatsView.swift
This commit is contained in:
parent
5bafc410b2
commit
4f5f24f132
@ -1,185 +1,106 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct ChatPreview: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var lastMessage: String
|
||||
var timeAgo: String
|
||||
var unread: Int
|
||||
var isOnline: Bool
|
||||
var isMuted: Bool
|
||||
var isVerified: Bool
|
||||
|
||||
init(id: UUID = UUID(), name: String, lastMessage: String, timeAgo: String,
|
||||
unread: Int = 0, isOnline: Bool = false, isMuted: Bool = false, isVerified: Bool = false) {
|
||||
self.id = id; self.name = name; self.lastMessage = lastMessage
|
||||
self.timeAgo = timeAgo; self.unread = unread
|
||||
self.isOnline = isOnline; self.isMuted = isMuted; self.isVerified = isVerified
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatMessage: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let text: String
|
||||
let isOutgoing: Bool
|
||||
let time: String
|
||||
var read: Bool
|
||||
|
||||
init(text: String, isOutgoing: Bool, time: String, read: Bool = false) {
|
||||
self.id = UUID(); self.text = text; self.isOutgoing = isOutgoing
|
||||
self.time = time; self.read = read
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ChatsStore: ObservableObject {
|
||||
static let shared = ChatsStore()
|
||||
|
||||
@Published var chats: [ChatPreview] = [
|
||||
ChatPreview(name: "Юнона", lastMessage: "Окно закрыто. Якорь в мос-фра-зел совпал.", timeAgo: "сейчас", unread: 2, isOnline: true, isVerified: true),
|
||||
ChatPreview(name: "Frankfurt Node", lastMessage: "VDF готов через 1.2с. Передаю в очередь.", timeAgo: "3м", isOnline: true, isVerified: true),
|
||||
ChatPreview(name: "Алёна К.", lastMessage: "Получила перевод, спасибо", timeAgo: "12м"),
|
||||
ChatPreview(name: "Helsinki Node", lastMessage: "Reality TLS handshake — норм.", timeAgo: "1ч", isOnline: true, isMuted: true, isVerified: true),
|
||||
ChatPreview(name: "Команда Montana", lastMessage: "Деплой 3.42.0 на Frankfurt — готов.", timeAgo: "3ч", unread: 5),
|
||||
ChatPreview(name: "Moscow Node", lastMessage: "efir.org поднят на :8443.", timeAgo: "вчера", isOnline: true, isMuted: true, isVerified: true),
|
||||
ChatPreview(name: "Игорь Б.", lastMessage: "Скинь mnemonic как восстановишь.", timeAgo: "вчера"),
|
||||
ChatPreview(name: "TimeChain Bot", lastMessage: "Window 2891845 sealed at 14:22:11 UTC", timeAgo: "пн", isOnline: true, isMuted: true, isVerified: true),
|
||||
]
|
||||
|
||||
@Published private var messagesByChat: [UUID: [ChatMessage]] = [:]
|
||||
|
||||
func messages(for chat: ChatPreview) -> [ChatMessage] {
|
||||
if let m = messagesByChat[chat.id] { return m }
|
||||
let seed: [ChatMessage] = [
|
||||
ChatMessage(text: "Привет. Окно \(Int.random(in: 2_891_800...2_891_900)) закрыто.", isOutgoing: false, time: "14:21", read: true),
|
||||
ChatMessage(text: "VDF подтверждён всеми тремя узлами.", isOutgoing: false, time: "14:21", read: true),
|
||||
ChatMessage(text: "Принял. Ставим якорь?", isOutgoing: true, time: "14:22", read: true),
|
||||
ChatMessage(text: "Да, якорь установлен. мос-фра-зел совпали.", isOutgoing: false, time: "14:22", read: true),
|
||||
]
|
||||
messagesByChat[chat.id] = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
func send(_ text: String, to chat: ChatPreview) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let f = DateFormatter(); f.dateFormat = "HH:mm"
|
||||
let msg = ChatMessage(text: trimmed, isOutgoing: true, time: f.string(from: Date()), read: false)
|
||||
messagesByChat[chat.id, default: []].append(msg)
|
||||
if let i = chats.firstIndex(where: { $0.id == chat.id }) {
|
||||
chats[i].lastMessage = trimmed
|
||||
chats[i].timeAgo = "сейчас"
|
||||
chats[i].unread = 0
|
||||
let updated = chats.remove(at: i)
|
||||
chats.insert(updated, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(_ chat: ChatPreview) {
|
||||
if let i = chats.firstIndex(where: { $0.id == chat.id }) {
|
||||
chats[i].unread = 0
|
||||
}
|
||||
}
|
||||
|
||||
func togglePin(_ chat: ChatPreview) {
|
||||
if let i = chats.firstIndex(where: { $0.id == chat.id }) {
|
||||
let item = chats.remove(at: i)
|
||||
chats.insert(item, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleMute(_ chat: ChatPreview) {
|
||||
if let i = chats.firstIndex(where: { $0.id == chat.id }) {
|
||||
chats[i].isMuted.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
func delete(_ chat: ChatPreview) {
|
||||
chats.removeAll { $0.id == chat.id }
|
||||
messagesByChat[chat.id] = nil
|
||||
}
|
||||
|
||||
func startChat(with name: String) -> ChatPreview {
|
||||
if let existing = chats.first(where: { $0.name.lowercased() == name.lowercased() }) {
|
||||
return existing
|
||||
}
|
||||
let new = ChatPreview(name: name, lastMessage: "Новый разговор", timeAgo: "сейчас", isOnline: false)
|
||||
chats.insert(new, at: 0)
|
||||
return new
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatsView: View {
|
||||
@StateObject private var store = ChatsStore.shared
|
||||
@State private var searchText = ""
|
||||
@EnvironmentObject private var identity: IdentityManager
|
||||
@StateObject private var chats = ChatsStore.shared
|
||||
@StateObject private var contacts = ContactsStore.shared
|
||||
@State private var search = ""
|
||||
@State private var openChat: Contact?
|
||||
@State private var showNewChat = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(filteredChats) { chat in
|
||||
NavigationLink(value: chat) { ChatRow(chat: chat) }
|
||||
.buttonStyle(ChatRowButtonStyle())
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) { store.delete(chat) } label: {
|
||||
Label("Удалить", systemImage: "trash")
|
||||
}
|
||||
Button { store.toggleMute(chat) } label: {
|
||||
Label(chat.isMuted ? "Включить" : "Mute",
|
||||
systemImage: chat.isMuted ? "speaker.wave.2.fill" : "speaker.slash.fill")
|
||||
}.tint(ClaudeTheme.Palette.accent)
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button { store.togglePin(chat) } label: {
|
||||
Label("Закрепить", systemImage: "pin.fill")
|
||||
}.tint(ClaudeTheme.Palette.success)
|
||||
}
|
||||
Rectangle()
|
||||
.fill(ClaudeTheme.Palette.divider)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 80)
|
||||
}
|
||||
Group {
|
||||
if chatList.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.claudeBackground()
|
||||
.searchable(text: $searchText, prompt: "Поиск")
|
||||
|
||||
Button { showNewChat = true } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ClaudeTheme.Palette.bubbleOutText)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Circle().fill(ClaudeTheme.Palette.accent))
|
||||
.shadow(color: .black.opacity(0.45), radius: 14, y: 6)
|
||||
.background(Circle().fill(ClaudeTheme.Palette.goldGradient))
|
||||
.shadow(color: ClaudeTheme.Palette.gold.opacity(0.45), radius: 16, y: 4)
|
||||
}
|
||||
.padding(.trailing, ClaudeTheme.Spacing.lg)
|
||||
.padding(.bottom, ClaudeTheme.Spacing.lg)
|
||||
}
|
||||
.navigationTitle("Montana")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.navigationDestination(for: ChatPreview.self) { chat in
|
||||
ChatDetailView(chat: chat).environmentObject(store)
|
||||
.onAppear { store.markRead(chat) }
|
||||
.navigationDestination(item: $openChat) { c in
|
||||
ChatDetailView(contact: c)
|
||||
}
|
||||
.sheet(isPresented: $showNewChat) {
|
||||
NewChatView { name in
|
||||
NewChatPickerView(onSelect: { c in
|
||||
showNewChat = false
|
||||
_ = store.startChat(with: name)
|
||||
}
|
||||
.environmentObject(store)
|
||||
openChat = c
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredChats: [ChatPreview] {
|
||||
guard !searchText.isEmpty else { return store.chats }
|
||||
return store.chats.filter { $0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.lastMessage.localizedCaseInsensitiveContains(searchText) }
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: ClaudeTheme.Spacing.lg) {
|
||||
Spacer()
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(ClaudeTheme.Palette.gold.opacity(0.7))
|
||||
VStack(spacing: 6) {
|
||||
Text("Чатов пока нет")
|
||||
.font(ClaudeTheme.Typography.title)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
||||
Text("Добавь контакт и начни разговор.")
|
||||
.font(ClaudeTheme.Typography.callout)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(filtered, id: \.peer.accountID) { item in
|
||||
Button { openChat = item.peer } label: {
|
||||
ChatRow(contact: item.peer, last: item.last,
|
||||
unread: chats.unreadByPeer[item.peer.accountID] ?? 0)
|
||||
}
|
||||
.buttonStyle(ChatRowButtonStyle())
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
ContactsStore.shared.delete(item.peer.accountID)
|
||||
} label: {
|
||||
Label("Удалить", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
Rectangle().fill(ClaudeTheme.Palette.divider)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $search, prompt: "Поиск")
|
||||
}
|
||||
|
||||
private var chatList: [(peer: Contact, last: LocalMessage?)] {
|
||||
chats.chats()
|
||||
}
|
||||
|
||||
private var filtered: [(peer: Contact, last: LocalMessage?)] {
|
||||
guard !search.trimmingCharacters(in: .whitespaces).isEmpty else { return chatList }
|
||||
return chatList.filter {
|
||||
$0.peer.name.localizedCaseInsensitiveContains(search) ||
|
||||
$0.peer.accountID.localizedCaseInsensitiveContains(search) ||
|
||||
($0.last?.text.localizedCaseInsensitiveContains(search) ?? false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -191,58 +112,42 @@ private struct ChatRowButtonStyle: ButtonStyle {
|
||||
}
|
||||
|
||||
private struct ChatRow: View {
|
||||
let chat: ChatPreview
|
||||
let contact: Contact
|
||||
let last: LocalMessage?
|
||||
let unread: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: ClaudeTheme.Spacing.md) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
AvatarView(name: chat.name, size: 52)
|
||||
if chat.isOnline {
|
||||
Circle()
|
||||
.fill(ClaudeTheme.Palette.success)
|
||||
.frame(width: 14, height: 14)
|
||||
.overlay(Circle().stroke(ClaudeTheme.Palette.canvas, lineWidth: 2.5))
|
||||
}
|
||||
}
|
||||
AvatarView(name: contact.name, size: 52)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Text(chat.name)
|
||||
Text(contact.name)
|
||||
.font(ClaudeTheme.Typography.headline)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
if chat.isVerified {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(ClaudeTheme.Palette.accent)
|
||||
}
|
||||
if chat.isMuted {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
||||
}
|
||||
Spacer()
|
||||
Text(chat.timeAgo)
|
||||
if let last {
|
||||
Text(formatTime(last.sentAt))
|
||||
.font(ClaudeTheme.Typography.caption)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text(chat.lastMessage)
|
||||
Text(last?.text ?? "Открыть разговор…")
|
||||
.font(ClaudeTheme.Typography.callout)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textSecondary)
|
||||
.foregroundStyle(last == nil
|
||||
? ClaudeTheme.Palette.textTertiary
|
||||
: ClaudeTheme.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if chat.unread > 0 {
|
||||
Text("\(chat.unread)")
|
||||
if unread > 0 {
|
||||
Text("\(unread)")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(ClaudeTheme.Palette.bubbleOutText)
|
||||
.frame(minWidth: 22, minHeight: 22)
|
||||
.padding(.horizontal, 6)
|
||||
.background(
|
||||
Capsule().fill(chat.isMuted
|
||||
? ClaudeTheme.Palette.textTertiary
|
||||
: ClaudeTheme.Palette.accent)
|
||||
)
|
||||
.background(Capsule().fill(ClaudeTheme.Palette.goldGradient))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -251,233 +156,148 @@ private struct ChatRow: View {
|
||||
.padding(.vertical, ClaudeTheme.Spacing.md)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func formatTime(_ ms: Int) -> String {
|
||||
let d = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
|
||||
let f = DateFormatter()
|
||||
if Calendar.current.isDateInToday(d) {
|
||||
f.dateFormat = "HH:mm"
|
||||
} else if Calendar.current.isDateInYesterday(d) {
|
||||
return "вчера"
|
||||
} else {
|
||||
f.dateFormat = "d MMM"
|
||||
}
|
||||
return f.string(from: d)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatDetailView: View {
|
||||
let chat: ChatPreview
|
||||
@EnvironmentObject private var store: ChatsStore
|
||||
let contact: Contact
|
||||
@EnvironmentObject private var identity: IdentityManager
|
||||
@StateObject private var chats = ChatsStore.shared
|
||||
@State private var input = ""
|
||||
@State private var showAttach = false
|
||||
@State private var showCallAlert = false
|
||||
@State private var showInfo = false
|
||||
@State private var sending = false
|
||||
@FocusState private var inputFocused: Bool
|
||||
|
||||
var messages: [LocalMessage] {
|
||||
chats.messages(for: contact.accountID)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(store.messages(for: chat)) { msg in
|
||||
if messages.isEmpty {
|
||||
emptyState
|
||||
.padding(.top, 80)
|
||||
} else {
|
||||
ForEach(messages) { msg in
|
||||
MessageBubble(message: msg).id(msg.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, ClaudeTheme.Spacing.md)
|
||||
.padding(.vertical, ClaudeTheme.Spacing.md)
|
||||
}
|
||||
.claudeBackground()
|
||||
.onChange(of: store.messages(for: chat).count) { _, _ in
|
||||
if let last = store.messages(for: chat).last {
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
if let last = messages.last {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let last = store.messages(for: chat).last {
|
||||
chats.markRead(contact.accountID)
|
||||
if let last = messages.last {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ComposeBar(text: $input, focused: $inputFocused,
|
||||
onSend: { sendCurrent() },
|
||||
onAttach: { showAttach = true })
|
||||
sending: sending,
|
||||
onSend: { Task { await sendCurrent() } })
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Button { showInfo = true } label: {
|
||||
HStack(spacing: 10) {
|
||||
AvatarView(name: chat.name, size: 32)
|
||||
AvatarView(name: contact.name, size: 32)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(chat.name)
|
||||
Text(contact.name)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
||||
Text(chat.isOnline ? "в сети" : "был(а) недавно")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(chat.isOnline ? ClaudeTheme.Palette.success : ClaudeTheme.Palette.textTertiary)
|
||||
Text(contact.accountID)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showCallAlert = true } label: {
|
||||
Image(systemName: "phone.fill")
|
||||
.foregroundStyle(ClaudeTheme.Palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.alert("Звонок", isPresented: $showCallAlert) {
|
||||
Button("Отмена", role: .cancel) {}
|
||||
Button("Позвонить") {}
|
||||
} message: {
|
||||
Text("Зашифрованный голосовой звонок \(chat.name) через сеть Montana.")
|
||||
}
|
||||
.confirmationDialog("Прикрепить", isPresented: $showAttach, titleVisibility: .visible) {
|
||||
Button("Фото или видео") { sendStub("📷 Фото") }
|
||||
Button("Файл") { sendStub("📎 Файл") }
|
||||
Button("Геолокация") { sendStub("📍 Москва, центр") }
|
||||
Button("Контакт") { sendStub("👤 Контакт") }
|
||||
Button("Перевод TimeCoin") { sendStub("💸 100 ₮ → \(chat.name)") }
|
||||
Button("Отмена", role: .cancel) {}
|
||||
}
|
||||
.sheet(isPresented: $showInfo) {
|
||||
NavigationStack {
|
||||
ChatInfoView(chat: chat)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(ClaudeTheme.Palette.gold.opacity(0.6))
|
||||
Text("Сообщения зашифрованы")
|
||||
.font(ClaudeTheme.Typography.headline)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textSecondary)
|
||||
Text("Никто кроме вас двоих не прочтёт переписку.")
|
||||
.font(ClaudeTheme.Typography.caption)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCurrent() {
|
||||
store.send(input, to: chat)
|
||||
@MainActor
|
||||
private func sendCurrent() async {
|
||||
guard let id = identity.identity else { return }
|
||||
let text = input
|
||||
input = ""
|
||||
}
|
||||
|
||||
private func sendStub(_ text: String) {
|
||||
store.send(text, to: chat)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatInfoView: View {
|
||||
let chat: ChatPreview
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: ClaudeTheme.Spacing.lg) {
|
||||
AvatarView(name: chat.name, size: 96)
|
||||
.padding(.top, ClaudeTheme.Spacing.xl)
|
||||
Text(chat.name)
|
||||
.font(ClaudeTheme.Typography.title)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
||||
Text(chat.isOnline ? "в сети" : "был(а) недавно")
|
||||
.font(ClaudeTheme.Typography.caption)
|
||||
.foregroundStyle(chat.isOnline ? ClaudeTheme.Palette.success : ClaudeTheme.Palette.textTertiary)
|
||||
|
||||
HStack(spacing: ClaudeTheme.Spacing.lg) {
|
||||
InfoAction(icon: "phone.fill", label: "Звонок")
|
||||
InfoAction(icon: "video.fill", label: "Видео")
|
||||
InfoAction(icon: "magnifyingglass", label: "Поиск")
|
||||
InfoAction(icon: "speaker.slash.fill", label: chat.isMuted ? "Unmute" : "Mute")
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
InfoRow(icon: "shield.lefthalf.filled", title: "Шифрование", value: "ML-DSA-65")
|
||||
InfoRow(icon: "clock.fill", title: "Создан", value: "Окно 2891840")
|
||||
InfoRow(icon: "person.fill", title: "Account ID", value: "0x7a3f…b921")
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ClaudeTheme.Radius.md, style: .continuous)
|
||||
.fill(ClaudeTheme.Palette.surface)
|
||||
)
|
||||
.padding(.horizontal, ClaudeTheme.Spacing.lg)
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
}
|
||||
.claudeBackground()
|
||||
.navigationTitle("Информация")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Готово") { dismiss() }
|
||||
.foregroundStyle(ClaudeTheme.Palette.accent)
|
||||
}
|
||||
}
|
||||
.toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
|
||||
private struct InfoAction: View {
|
||||
let icon: String; let label: String
|
||||
var body: some View {
|
||||
Button {} label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(ClaudeTheme.Palette.accent)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(Circle().fill(ClaudeTheme.Palette.surface))
|
||||
Text(label)
|
||||
.font(ClaudeTheme.Typography.caption)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InfoRow: View {
|
||||
let icon: String; let title: String; let value: String
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(ClaudeTheme.Palette.accent)
|
||||
.frame(width: 28)
|
||||
Text(title)
|
||||
.font(ClaudeTheme.Typography.body)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(ClaudeTheme.Typography.callout)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, ClaudeTheme.Spacing.md)
|
||||
.padding(.vertical, ClaudeTheme.Spacing.md)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5).padding(.leading, 44)
|
||||
sending = true
|
||||
defer { sending = false }
|
||||
do {
|
||||
try await chats.send(text: text, to: contact, identity: id)
|
||||
} catch {
|
||||
input = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageBubble: View {
|
||||
let message: ChatMessage
|
||||
let message: LocalMessage
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if message.isOutgoing { Spacer(minLength: 48) }
|
||||
if message.outgoing { Spacer(minLength: 48) }
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(message.text)
|
||||
.font(ClaudeTheme.Typography.body)
|
||||
.foregroundStyle(message.isOutgoing
|
||||
.foregroundStyle(message.outgoing
|
||||
? ClaudeTheme.Palette.bubbleOutText
|
||||
: ClaudeTheme.Palette.bubbleInText)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
HStack(spacing: 3) {
|
||||
Text(message.time)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(formatTime(message.sentAt))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(message.isOutgoing
|
||||
? Color.white.opacity(0.78)
|
||||
.foregroundStyle(message.outgoing
|
||||
? ClaudeTheme.Palette.bubbleOutText.opacity(0.7)
|
||||
: ClaudeTheme.Palette.textTertiary)
|
||||
if message.isOutgoing {
|
||||
Image(systemName: message.read ? "checkmark.circle.fill" : "checkmark")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Color.white.opacity(0.78))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
.background(
|
||||
Group {
|
||||
if message.isOutgoing {
|
||||
if message.outgoing {
|
||||
RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous)
|
||||
.fill(ClaudeTheme.Palette.bubbleOutGradient)
|
||||
.fill(ClaudeTheme.Palette.goldGradient)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous)
|
||||
.fill(ClaudeTheme.Palette.surface)
|
||||
@ -488,26 +308,27 @@ private struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
if !message.isOutgoing { Spacer(minLength: 48) }
|
||||
.frame(maxWidth: 280, alignment: message.outgoing ? .trailing : .leading)
|
||||
if !message.outgoing { Spacer(minLength: 48) }
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ ms: Int) -> String {
|
||||
let d = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm"
|
||||
return f.string(from: d)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ComposeBar: View {
|
||||
@Binding var text: String
|
||||
var focused: FocusState<Bool>.Binding
|
||||
let sending: Bool
|
||||
let onSend: () -> Void
|
||||
let onAttach: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: ClaudeTheme.Spacing.sm) {
|
||||
Button(action: onAttach) {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
||||
}
|
||||
.padding(.leading, ClaudeTheme.Spacing.md)
|
||||
|
||||
HStack {
|
||||
TextField("Сообщение", text: $text, axis: .vertical)
|
||||
.font(ClaudeTheme.Typography.body)
|
||||
@ -516,24 +337,37 @@ private struct ComposeBar: View {
|
||||
.focused(focused)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(onSend)
|
||||
Image(systemName: "face.smiling")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(ClaudeTheme.Palette.surface)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(ClaudeTheme.Palette.goldHairline, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.leading, ClaudeTheme.Spacing.md)
|
||||
|
||||
Button(action: { if !text.isEmpty { onSend() } }) {
|
||||
Image(systemName: text.isEmpty ? "mic.fill" : "arrow.up")
|
||||
Button(action: onSend) {
|
||||
Group {
|
||||
if sending {
|
||||
ProgressView().tint(ClaudeTheme.Palette.bubbleOutText)
|
||||
} else {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(ClaudeTheme.Palette.accent))
|
||||
.foregroundStyle(ClaudeTheme.Palette.bubbleOutText)
|
||||
}
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.background(
|
||||
Circle().fill(canSend
|
||||
? AnyShapeStyle(ClaudeTheme.Palette.goldGradient)
|
||||
: AnyShapeStyle(ClaudeTheme.Palette.surface))
|
||||
)
|
||||
}
|
||||
.disabled(!canSend)
|
||||
.padding(.trailing, ClaudeTheme.Spacing.md)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
@ -542,71 +376,62 @@ private struct ComposeBar: View {
|
||||
Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !sending
|
||||
}
|
||||
}
|
||||
|
||||
struct NewChatView: View {
|
||||
@EnvironmentObject private var store: ChatsStore
|
||||
private struct NewChatPickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onSelect: (String) -> Void
|
||||
@State private var query = ""
|
||||
|
||||
private let suggestions = [
|
||||
"Алик Монтана", "Юнона", "Алёна К.", "Игорь Б.",
|
||||
"Frankfurt Node", "Helsinki Node", "Moscow Node", "TimeChain Bot",
|
||||
]
|
||||
@StateObject private var contacts = ContactsStore.shared
|
||||
let onSelect: (Contact) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if contacts.contacts.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Spacer()
|
||||
Image(systemName: "person.crop.circle")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(ClaudeTheme.Palette.gold.opacity(0.5))
|
||||
Text("Сначала добавь контакт")
|
||||
.font(ClaudeTheme.Typography.headline)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if !query.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||
!suggestions.contains(where: { $0.localizedCaseInsensitiveContains(query) }) {
|
||||
Button {
|
||||
onSelect(query.trimmingCharacters(in: .whitespaces))
|
||||
} label: {
|
||||
ForEach(contacts.contacts) { c in
|
||||
Button { onSelect(c) } label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(ClaudeTheme.Palette.accent)
|
||||
AvatarView(name: c.name, size: 44)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Начать чат с «\(query)»")
|
||||
Text(c.name)
|
||||
.font(ClaudeTheme.Typography.headline)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
||||
Text("Новый собеседник")
|
||||
.font(ClaudeTheme.Typography.caption)
|
||||
Text(c.accountID)
|
||||
.font(ClaudeTheme.Typography.mono)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, ClaudeTheme.Spacing.lg)
|
||||
.padding(.vertical, ClaudeTheme.Spacing.md)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5)
|
||||
Rectangle().fill(ClaudeTheme.Palette.divider)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 76)
|
||||
}
|
||||
|
||||
ForEach(filtered, id: \.self) { name in
|
||||
Button { onSelect(name) } label: {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(name: name, size: 44)
|
||||
VStack(alignment: .leading) {
|
||||
Text(name)
|
||||
.font(ClaudeTheme.Typography.headline)
|
||||
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
||||
Text("в сети")
|
||||
.font(ClaudeTheme.Typography.caption)
|
||||
.foregroundStyle(ClaudeTheme.Palette.success)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, ClaudeTheme.Spacing.lg)
|
||||
.padding(.vertical, ClaudeTheme.Spacing.md)
|
||||
}
|
||||
Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5).padding(.leading, 76)
|
||||
}
|
||||
}
|
||||
}
|
||||
.claudeBackground()
|
||||
.searchable(text: $query, prompt: "Имя или Account ID")
|
||||
.navigationTitle("Новый чат")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@ -619,9 +444,4 @@ struct NewChatView: View {
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
|
||||
private var filtered: [String] {
|
||||
guard !query.isEmpty else { return suggestions }
|
||||
return suggestions.filter { $0.localizedCaseInsensitiveContains(query) }
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user