448 lines
18 KiB
Swift
448 lines
18 KiB
Swift
import SwiftUI
|
|
import Combine
|
|
|
|
struct ChatsView: View {
|
|
@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) {
|
|
Group {
|
|
if chatList.isEmpty {
|
|
emptyState
|
|
} else {
|
|
list
|
|
}
|
|
}
|
|
.claudeBackground()
|
|
|
|
Button { showNewChat = true } label: {
|
|
Image(systemName: "square.and.pencil")
|
|
.font(.system(size: 22, weight: .medium))
|
|
.foregroundStyle(ClaudeTheme.Palette.bubbleOutText)
|
|
.frame(width: 56, height: 56)
|
|
.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)
|
|
.navigationDestination(item: $openChat) { c in
|
|
ChatDetailView(contact: c)
|
|
}
|
|
.sheet(isPresented: $showNewChat) {
|
|
NewChatPickerView(onSelect: { c in
|
|
showNewChat = false
|
|
openChat = c
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ChatRowButtonStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.background(configuration.isPressed ? ClaudeTheme.Palette.surfaceHover : Color.clear)
|
|
}
|
|
}
|
|
|
|
private struct ChatRow: View {
|
|
let contact: Contact
|
|
let last: LocalMessage?
|
|
let unread: Int
|
|
|
|
var body: some View {
|
|
HStack(spacing: ClaudeTheme.Spacing.md) {
|
|
AvatarView(name: contact.name, size: 52)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 4) {
|
|
Text(contact.name)
|
|
.font(ClaudeTheme.Typography.headline)
|
|
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
if let last {
|
|
Text(formatTime(last.sentAt))
|
|
.font(ClaudeTheme.Typography.caption)
|
|
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
|
}
|
|
}
|
|
HStack {
|
|
Text(last?.text ?? "Открыть разговор…")
|
|
.font(ClaudeTheme.Typography.callout)
|
|
.foregroundStyle(last == nil
|
|
? ClaudeTheme.Palette.textTertiary
|
|
: ClaudeTheme.Palette.textSecondary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
if unread > 0 {
|
|
Text("\(unread)")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(ClaudeTheme.Palette.bubbleOutText)
|
|
.frame(minWidth: 22, minHeight: 22)
|
|
.padding(.horizontal, 6)
|
|
.background(Capsule().fill(ClaudeTheme.Palette.goldGradient))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, ClaudeTheme.Spacing.lg)
|
|
.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 contact: Contact
|
|
@EnvironmentObject private var identity: IdentityManager
|
|
@StateObject private var chats = ChatsStore.shared
|
|
@State private var input = ""
|
|
@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) {
|
|
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: messages.count) { _, _ in
|
|
if let last = messages.last {
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
proxy.scrollTo(last.id, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
chats.markRead(contact.accountID)
|
|
if let last = messages.last {
|
|
proxy.scrollTo(last.id, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
|
|
ComposeBar(text: $input, focused: $inputFocused,
|
|
sending: sending,
|
|
onSend: { Task { await sendCurrent() } })
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
HStack(spacing: 10) {
|
|
AvatarView(name: contact.name, size: 32)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(contact.name)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
|
Text(contact.accountID)
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar)
|
|
.toolbarBackground(.visible, for: .navigationBar)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func sendCurrent() async {
|
|
guard let id = identity.identity else { return }
|
|
let text = input
|
|
input = ""
|
|
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: LocalMessage
|
|
|
|
var body: some View {
|
|
HStack {
|
|
if message.outgoing { Spacer(minLength: 48) }
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(message.text)
|
|
.font(ClaudeTheme.Typography.body)
|
|
.foregroundStyle(message.outgoing
|
|
? ClaudeTheme.Palette.bubbleOutText
|
|
: ClaudeTheme.Palette.bubbleInText)
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 8)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Text(formatTime(message.sentAt))
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(message.outgoing
|
|
? ClaudeTheme.Palette.bubbleOutText.opacity(0.7)
|
|
: ClaudeTheme.Palette.textTertiary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, 6)
|
|
}
|
|
.background(
|
|
Group {
|
|
if message.outgoing {
|
|
RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous)
|
|
.fill(ClaudeTheme.Palette.goldGradient)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous)
|
|
.fill(ClaudeTheme.Palette.surface)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous)
|
|
.strokeBorder(ClaudeTheme.Palette.goldHairline, lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
.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
|
|
|
|
var body: some View {
|
|
HStack(spacing: ClaudeTheme.Spacing.sm) {
|
|
HStack {
|
|
TextField("Сообщение", text: $text, axis: .vertical)
|
|
.font(ClaudeTheme.Typography.body)
|
|
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
|
.lineLimit(1...5)
|
|
.focused(focused)
|
|
.submitLabel(.send)
|
|
.onSubmit(onSend)
|
|
}
|
|
.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: onSend) {
|
|
Group {
|
|
if sending {
|
|
ProgressView().tint(ClaudeTheme.Palette.bubbleOutText)
|
|
} else {
|
|
Image(systemName: "arrow.up")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.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)
|
|
.background(ClaudeTheme.Palette.canvas)
|
|
.overlay(alignment: .top) {
|
|
Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5)
|
|
}
|
|
}
|
|
|
|
private var canSend: Bool {
|
|
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !sending
|
|
}
|
|
}
|
|
|
|
private struct NewChatPickerView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@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) {
|
|
ForEach(contacts.contacts) { c in
|
|
Button { onSelect(c) } label: {
|
|
HStack(spacing: 12) {
|
|
AvatarView(name: c.name, size: 44)
|
|
VStack(alignment: .leading) {
|
|
Text(c.name)
|
|
.font(ClaudeTheme.Typography.headline)
|
|
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
|
|
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)
|
|
.padding(.leading, 76)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.claudeBackground()
|
|
.navigationTitle("Новый чат")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button("Отмена") { dismiss() }
|
|
.foregroundStyle(ClaudeTheme.Palette.textSecondary)
|
|
}
|
|
}
|
|
.toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar)
|
|
.toolbarBackground(.visible, for: .navigationBar)
|
|
}
|
|
}
|
|
}
|