ios_client
This commit is contained in:
382
ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift
Normal file
382
ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift
Normal file
@@ -0,0 +1,382 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@State private var conversation: Conversation
|
||||
var appState: AppState
|
||||
var conversationListVM: ConversationListVM?
|
||||
|
||||
init(conversation: Conversation, appState: AppState, conversationListVM: ConversationListVM? = nil) {
|
||||
self._conversation = State(initialValue: conversation)
|
||||
self.appState = appState
|
||||
self.conversationListVM = conversationListVM
|
||||
}
|
||||
@State private var viewModel = ChatViewModel()
|
||||
@State private var inputText = ""
|
||||
@State private var replyTo: Message?
|
||||
@State private var showGroupInfo = false
|
||||
@State private var showDMInfo = false
|
||||
@State private var showSearch = false
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var showError = false
|
||||
@State private var memberListenerTask: Task<Void, Never>?
|
||||
@State private var forwardingMessage: Message?
|
||||
@State private var showForwardPicker = false
|
||||
@State private var showPinnedMessages = false
|
||||
@State private var scrollTarget: String?
|
||||
@State private var showVerification = false
|
||||
@State private var verificationStatus: String = "unverified"
|
||||
|
||||
private var currentUserId: String {
|
||||
appState.currentUser?.id ?? ""
|
||||
}
|
||||
|
||||
private var isPartnerOnline: Bool {
|
||||
guard !conversation.isGroup,
|
||||
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId),
|
||||
let listVM = conversationListVM else {
|
||||
return false
|
||||
}
|
||||
return listVM.onlineUsers.contains(partnerId)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
searchBar
|
||||
messagesScrollView
|
||||
replyPreview
|
||||
inputView
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbarContent }
|
||||
.alert("Delete Conversation?", isPresented: $showDeleteConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete", role: .destructive) {
|
||||
Task { await appState.chatClient.deleteConversation(convId: conversation.id) }
|
||||
}
|
||||
} message: {
|
||||
Text(conversation.isGroup
|
||||
? "This will remove all members and delete the conversation."
|
||||
: "This will remove you from the conversation.")
|
||||
}
|
||||
.alert("Error", isPresented: $showError) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "Unknown error")
|
||||
}
|
||||
.sheet(isPresented: $showGroupInfo) {
|
||||
GroupInfoView(conversation: $conversation, appState: appState, conversationListVM: conversationListVM)
|
||||
}
|
||||
.sheet(isPresented: $showDMInfo) {
|
||||
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
|
||||
ProfileView(appState: appState, isOwnProfile: false, userId: partnerId)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showForwardPicker) {
|
||||
if let msg = forwardingMessage {
|
||||
ForwardPickerView(message: msg, appState: appState)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showVerification) {
|
||||
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
|
||||
NavigationStack {
|
||||
SafetyNumberView(
|
||||
peerUserId: partnerId,
|
||||
peerUsername: conversation.displayName(currentUserId: currentUserId),
|
||||
chatClient: appState.chatClient
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Done") { showVerification = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPinnedMessages) {
|
||||
PinnedMessagesView(
|
||||
messages: viewModel.messages.filter { $0.pinnedAt != nil },
|
||||
onScrollTo: { scrollTarget = $0 }
|
||||
)
|
||||
}
|
||||
.task {
|
||||
// Use already-loaded data from conversation list (avoid redundant list_conversations call)
|
||||
if let updated = conversationListVM?.conversations.first(where: { $0.id == conversation.id }) {
|
||||
conversation = updated
|
||||
}
|
||||
conversationListVM?.markConversationRead(convId: conversation.id)
|
||||
await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient)
|
||||
viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient)
|
||||
|
||||
// Load verification status for DM partner
|
||||
if !conversation.isGroup,
|
||||
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
|
||||
verificationStatus = await appState.chatClient.getVerificationStatus(userId: partnerId)
|
||||
}
|
||||
|
||||
memberListenerTask = Task {
|
||||
for await notification in await appState.chatClient.makeNotificationStream() {
|
||||
switch notification {
|
||||
case .memberAdded, .memberRemoved, .conversationRenamed:
|
||||
let refreshed = await appState.chatClient.listConversations()
|
||||
if let updated = refreshed.first(where: { $0.id == conversation.id }) {
|
||||
await MainActor.run { conversation = updated }
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.stop()
|
||||
memberListenerTask?.cancel()
|
||||
memberListenerTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Bar
|
||||
|
||||
@ViewBuilder
|
||||
private var searchBar: some View {
|
||||
if showSearch {
|
||||
SearchOverlayView(
|
||||
query: $viewModel.searchQuery,
|
||||
matchCount: viewModel.searchResults.count,
|
||||
currentIndex: viewModel.currentSearchIndex,
|
||||
onSearch: { viewModel.search(query: $0) },
|
||||
onNext: { viewModel.nextSearchResult() },
|
||||
onPrev: { viewModel.prevSearchResult() },
|
||||
onClose: { showSearch = false; viewModel.search(query: "") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Messages
|
||||
|
||||
private var messagesScrollView: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 8) {
|
||||
if viewModel.messages.count >= 50 {
|
||||
Button("Load older messages") {
|
||||
Task {
|
||||
await viewModel.loadOlderMessages(convId: conversation.id, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.padding()
|
||||
}
|
||||
|
||||
ForEach(viewModel.messages) { message in
|
||||
messageBubble(for: message)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.onChange(of: viewModel.messages.count) {
|
||||
if let lastId = viewModel.messages.last?.id {
|
||||
withAnimation { proxy.scrollTo(lastId, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
.onChange(of: scrollTarget) {
|
||||
if let target = scrollTarget {
|
||||
withAnimation { proxy.scrollTo(target, anchor: .center) }
|
||||
scrollTarget = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func messageBubble(for message: Message) -> some View {
|
||||
let isCurrentSearch = viewModel.searchResults.indices.contains(viewModel.currentSearchIndex)
|
||||
&& viewModel.searchResults[viewModel.currentSearchIndex] == message.id
|
||||
return MessageBubbleView(
|
||||
message: message,
|
||||
isMine: message.isMine(currentUserId: currentUserId),
|
||||
isGroup: conversation.isGroup,
|
||||
isHighlighted: viewModel.searchResults.contains(message.id),
|
||||
isCurrentSearchResult: isCurrentSearch,
|
||||
chatClient: appState.chatClient,
|
||||
currentUserId: currentUserId,
|
||||
onReply: { replyTo = message },
|
||||
onReact: { reaction in
|
||||
Task {
|
||||
await viewModel.reactToMessage(
|
||||
messageId: message.id, convId: conversation.id,
|
||||
reaction: reaction, currentUserId: currentUserId,
|
||||
chatClient: appState.chatClient
|
||||
)
|
||||
}
|
||||
},
|
||||
onForward: {
|
||||
forwardingMessage = message
|
||||
showForwardPicker = true
|
||||
},
|
||||
onPin: { pin in
|
||||
Task {
|
||||
await viewModel.pinMessage(
|
||||
messageId: message.id, convId: conversation.id,
|
||||
pin: pin, chatClient: appState.chatClient
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelete: {
|
||||
Task {
|
||||
await viewModel.deleteMessage(
|
||||
messageId: message.id, convId: conversation.id,
|
||||
chatClient: appState.chatClient
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Reply Preview
|
||||
|
||||
@ViewBuilder
|
||||
private var replyPreview: some View {
|
||||
if let reply = replyTo {
|
||||
HStack {
|
||||
Rectangle()
|
||||
.fill(.blue)
|
||||
.frame(width: 3)
|
||||
VStack(alignment: .leading) {
|
||||
Text(reply.senderUsername)
|
||||
.font(.caption.bold())
|
||||
Text(reply.text ?? "")
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: { replyTo = nil }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
private var inputView: some View {
|
||||
MessageInputView(
|
||||
text: $inputText,
|
||||
isSending: viewModel.isSending,
|
||||
onSend: {
|
||||
Task {
|
||||
let text = inputText
|
||||
inputText = ""
|
||||
let reply = replyTo?.id
|
||||
replyTo = nil
|
||||
await viewModel.sendMessage(
|
||||
convId: conversation.id, text: text,
|
||||
members: conversation.members,
|
||||
chatClient: appState.chatClient, replyTo: reply
|
||||
)
|
||||
}
|
||||
},
|
||||
onImageSelected: { imageData in
|
||||
Task {
|
||||
viewModel.isSending = true
|
||||
let (success, msg, sentMessage) = await appState.chatClient.sendImage(
|
||||
convId: conversation.id, imageData: imageData,
|
||||
members: conversation.members
|
||||
)
|
||||
viewModel.isSending = false
|
||||
if success, let sentMessage {
|
||||
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
|
||||
viewModel.messages.append(sentMessage)
|
||||
}
|
||||
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
|
||||
} else if !success {
|
||||
viewModel.errorMessage = msg
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onFileSelected: { fileData, filename, mimeType in
|
||||
Task {
|
||||
viewModel.isSending = true
|
||||
let (success, msg, sentMessage) = await appState.chatClient.sendFile(
|
||||
convId: conversation.id, fileData: fileData,
|
||||
filename: filename, mimeType: mimeType,
|
||||
members: conversation.members
|
||||
)
|
||||
viewModel.isSending = false
|
||||
if success, let sentMessage {
|
||||
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
|
||||
viewModel.messages.append(sentMessage)
|
||||
}
|
||||
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
|
||||
} else if !success {
|
||||
viewModel.errorMessage = msg
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
},
|
||||
members: conversation.members
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 8) {
|
||||
CircularAvatarView(
|
||||
name: conversation.displayName(currentUserId: currentUserId),
|
||||
imageData: conversationListVM?.avatarCache[conversation.id],
|
||||
size: 28,
|
||||
isGroup: conversation.isGroup
|
||||
)
|
||||
Text(conversation.displayName(currentUserId: currentUserId))
|
||||
.font(.headline)
|
||||
if !conversation.isGroup && verificationStatus == "verified" {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
if isPartnerOnline {
|
||||
Circle().fill(.green).frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
if !conversation.isGroup {
|
||||
Button(action: { showVerification = true }) {
|
||||
Image(systemName: verificationStatus == "verified" ? "checkmark.shield.fill" : "shield")
|
||||
.foregroundStyle(verificationStatus == "verified" ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
Button(action: { showPinnedMessages = true }) {
|
||||
Image(systemName: "pin")
|
||||
}
|
||||
Button(action: { showSearch.toggle() }) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
if conversation.isGroup {
|
||||
Button(action: { showGroupInfo = true }) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
} else {
|
||||
Button(action: { showDMInfo = true }) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
}
|
||||
if !conversation.isGroup || conversation.createdBy == currentUserId {
|
||||
Button(action: { showDeleteConfirm = true }) {
|
||||
Image(systemName: "trash").foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user