initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
import SwiftUI
struct ConversationListView: View {
var appState: AppState
@Bindable var viewModel: ConversationListVM
@State private var showNewConversation = false
@State private var showProfile = false
@State private var selectedConversation: Conversation?
var body: some View {
NavigationStack {
List {
// Invitations section
if !viewModel.invitations.isEmpty {
Section {
ForEach(viewModel.invitations) { invitation in
InvitationBanner(
invitation: invitation,
onAccept: {
Task {
let (success, _) = await appState.chatClient.acceptInvitation(convId: invitation.conversationId)
if success {
await viewModel.refresh(chatClient: appState.chatClient)
}
}
},
onDecline: {
Task {
await appState.chatClient.declineInvitation(convId: invitation.conversationId)
await viewModel.refresh(chatClient: appState.chatClient)
}
}
)
}
} header: {
Text("Invitations")
}
}
// Conversations section
Section {
ForEach(viewModel.conversations) { conversation in
NavigationLink(value: conversation) {
ConversationRowView(
conversation: conversation,
currentUserId: appState.currentUser?.id ?? "",
isOnline: conversation.dmPartnerId(currentUserId: appState.currentUser?.id ?? "")
.map { viewModel.onlineUsers.contains($0) } ?? false,
unreadCount: viewModel.unreadCounts[conversation.id] ?? 0
)
}
.contextMenu {
Button(conversation.isFavorite ? "Remove from Favorites" : "Add to Favorites") {
viewModel.toggleFavorite(convId: conversation.id, email: appState.email)
}
}
}
}
}
.navigationTitle("Chats")
.navigationDestination(for: Conversation.self) { conversation in
ChatView(
conversation: conversation,
appState: appState
)
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
ConnectionIndicator(status: appState.connectionStatus)
}
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button(action: { showProfile = true }) {
Image(systemName: "person.circle")
}
Button(action: { showNewConversation = true }) {
Image(systemName: "square.and.pencil")
}
}
}
}
.refreshable {
await viewModel.refresh(chatClient: appState.chatClient)
}
.sheet(isPresented: $showNewConversation) {
NewConversationSheet(appState: appState) { convId in
showNewConversation = false
await viewModel.refresh(chatClient: appState.chatClient)
}
}
.sheet(isPresented: $showProfile) {
ProfileView(appState: appState, isOwnProfile: true)
}
.task {
await viewModel.load(chatClient: appState.chatClient, email: appState.email)
}
}
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct ConversationRowView: View {
let conversation: Conversation
let currentUserId: String
let isOnline: Bool
let unreadCount: Int
var body: some View {
HStack(spacing: 12) {
// Avatar
ZStack(alignment: .bottomTrailing) {
CircularAvatarView(
name: conversation.displayName(currentUserId: currentUserId),
size: 44,
isGroup: conversation.isGroup
)
if isOnline && !conversation.isGroup {
OnlineDotOverlay(size: 12)
}
}
VStack(alignment: .leading, spacing: 2) {
HStack {
if conversation.isFavorite {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Text(conversation.displayName(currentUserId: currentUserId))
.font(unreadCount > 0 ? .body.bold() : .body)
.lineLimit(1)
}
if conversation.isGroup {
Text("\(conversation.members.count) members")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if unreadCount > 0 {
Text("\(unreadCount)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.blue)
.clipShape(Capsule())
}
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,100 @@
import SwiftUI
struct NewConversationSheet: View {
var appState: AppState
var onCreated: (String) async -> Void
@State private var email = ""
@State private var groupName = ""
@State private var isGroup = false
@State private var memberEmails: [String] = [""]
@State private var isLoading = false
@State private var errorMessage: String?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section {
Toggle("Create Group", isOn: $isGroup)
if isGroup {
TextField("Group Name", text: $groupName)
}
}
Section(isGroup ? "Members" : "Recipient") {
if isGroup {
ForEach(memberEmails.indices, id: \.self) { index in
TextField("Email", text: $memberEmails[index])
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
Button("Add Member") {
memberEmails.append("")
}
} else {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
}
if let error = errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.navigationTitle("New Conversation")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
Task { await create() }
}
.disabled(isLoading)
}
}
}
}
private func create() async {
isLoading = true
errorMessage = nil
let emails: [String]
if isGroup {
emails = memberEmails.map { $0.trimmed }.filter { !$0.isEmpty }
guard !emails.isEmpty else {
errorMessage = "Add at least one member"
isLoading = false
return
}
} else {
guard !email.trimmed.isEmpty else {
errorMessage = "Enter an email address"
isLoading = false
return
}
emails = [email.trimmed]
}
let name = isGroup && !groupName.trimmed.isEmpty ? groupName.trimmed : nil
let (convId, message) = await appState.chatClient.createConversation(emails: emails, name: name)
isLoading = false
if let convId = convId {
await onCreated(convId)
} else {
errorMessage = message
}
}
}