ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

View File

@@ -0,0 +1,301 @@
import SwiftUI
import PhotosUI
struct GroupInfoView: View {
@Binding var conversation: Conversation
var appState: AppState
var conversationListVM: ConversationListVM?
@State private var showRenameSheet = false
@State private var showLeaveConfirm = false
@State private var showAddMember = false
@State private var showRemoveConfirm = false
@State private var showAvatarPicker = false
@State private var newName = ""
@State private var addMemberEmail = ""
@State private var memberToRemove: ConversationMember?
@State private var errorMessage: String?
@State private var showError = false
@State private var isUploadingAvatar = false
@State private var groupAvatarData: Data?
@Environment(\.dismiss) private var dismiss
private var isCreator: Bool {
conversation.createdBy == appState.currentUser?.id
}
private func refreshConversation() async {
let convs = await appState.chatClient.listConversations()
if let updated = convs.first(where: { $0.id == conversation.id }) {
conversation = updated
}
await conversationListVM?.forceRefresh(chatClient: appState.chatClient)
}
var body: some View {
NavigationStack {
List {
// Avatar section
Section {
HStack {
Spacer()
VStack(spacing: 8) {
CircularAvatarView(
name: conversation.name ?? "Group",
imageData: groupAvatarData ?? conversationListVM?.avatarCache[conversation.id],
size: 64,
isGroup: true
)
Text(conversation.name ?? "Group")
.font(.title2.bold())
Text("\(conversation.members.count) members")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.listRowBackground(Color.clear)
}
// Actions
if isCreator {
Section {
Button("Add Member") {
addMemberEmail = ""
showAddMember = true
}
Button("Rename Group") {
newName = conversation.name ?? ""
showRenameSheet = true
}
Button {
showAvatarPicker = true
} label: {
HStack {
Text("Change Avatar")
if isUploadingAvatar {
Spacer()
ProgressView()
.scaleEffect(0.8)
}
}
}
.disabled(isUploadingAvatar)
}
}
// Members
Section("Members") {
ForEach(conversation.members) { member in
HStack {
CircularAvatarView(name: member.username, size: 32, isGroup: false)
VStack(alignment: .leading) {
Text(member.username)
.font(.body)
Text(member.email)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if member.userId == conversation.createdBy {
Text("Admin")
.font(.caption)
.foregroundStyle(.blue)
}
}
.contextMenu {
if isCreator && member.userId != appState.currentUser?.id {
Button("Remove from Group", role: .destructive) {
memberToRemove = member
showRemoveConfirm = true
}
}
}
}
}
// Leave / Delete
Section {
Button("Leave Group", role: .destructive) {
showLeaveConfirm = true
}
if isCreator {
Button("Delete Group", role: .destructive) {
Task {
_ = await appState.chatClient.deleteConversation(convId: conversation.id)
dismiss()
}
}
}
}
}
.navigationTitle("Group Info")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
.alert("Leave Group?", isPresented: $showLeaveConfirm) {
Button("Cancel", role: .cancel) {}
Button("Leave", role: .destructive) {
Task {
_ = await appState.chatClient.leaveGroup(convId: conversation.id)
dismiss()
}
}
}
.alert("Remove Member?", isPresented: $showRemoveConfirm) {
Button("Cancel", role: .cancel) {}
Button("Remove", role: .destructive) {
if let member = memberToRemove {
Task {
let (success, msg) = await appState.chatClient.removeMember(
convId: conversation.id, userId: member.userId
)
if success {
await refreshConversation()
} else {
errorMessage = msg
showError = true
}
}
}
}
} message: {
if let member = memberToRemove {
Text("Remove \(member.username) from the group?")
}
}
.alert("Add Member", isPresented: $showAddMember) {
TextField("Email", text: $addMemberEmail)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
Button("Cancel", role: .cancel) {}
Button("Add") {
let email = addMemberEmail.trimmingCharacters(in: .whitespacesAndNewlines)
guard !email.isEmpty else { return }
Task {
let (success, msg) = await appState.chatClient.addMember(
convId: conversation.id, email: email
)
if success {
await refreshConversation()
} else {
errorMessage = msg
showError = true
}
}
}
}
.alert("Rename Group", isPresented: $showRenameSheet) {
TextField("Group Name", text: $newName)
Button("Cancel", role: .cancel) {}
Button("Rename") {
let trimmedName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
// Optimistic update - immediately reflect in UI
conversation.name = trimmedName
Task {
let (success, _) = await appState.chatClient.renameConversation(convId: conversation.id, name: trimmedName)
if success {
await refreshConversation()
} else {
// Revert on failure
await refreshConversation()
}
}
}
}
.alert("Error", isPresented: $showError) {
Button("OK") {}
} message: {
Text(errorMessage ?? "")
}
.sheet(isPresented: $showAvatarPicker) {
AvatarPickerView { imageData in
isUploadingAvatar = true
Task {
let success = await appState.chatClient.updateGroupAvatar(
convId: conversation.id, imageData: imageData
)
isUploadingAvatar = false
if success {
// Update local avatar cache (memory + disk)
groupAvatarData = imageData
conversationListVM?.updateAvatar(convId: conversation.id, data: imageData)
await refreshConversation()
} else {
errorMessage = "Failed to update avatar"
showError = true
}
}
}
}
.task {
// Load current group avatar
if groupAvatarData == nil, let cached = conversationListVM?.avatarCache[conversation.id] {
groupAvatarData = cached
} else if groupAvatarData == nil {
groupAvatarData = await appState.chatClient.getGroupAvatar(convId: conversation.id)
}
await refreshConversation()
}
}
}
}
// MARK: - Avatar Picker (PHPicker wrapper for avatar selection)
private struct AvatarPickerView: UIViewControllerRepresentable {
let onImagePicked: (Data) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onImagePicked: onImagePicked)
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let onImagePicked: (Data) -> Void
init(onImagePicked: @escaping (Data) -> Void) {
self.onImagePicked = onImagePicked
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let provider = results.first?.itemProvider,
provider.canLoadObject(ofClass: UIImage.self) else {
picker.dismiss(animated: true)
return
}
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
guard let uiImage = image as? UIImage,
let data = uiImage.jpegData(compressionQuality: 0.8) else {
DispatchQueue.main.async { picker.dismiss(animated: true) }
return
}
DispatchQueue.main.async {
self?.onImagePicked(data)
picker.dismiss(animated: true)
}
}
}
}
}