import SwiftUI public struct RootView: View { @StateObject private var store: SessionStore @StateObject private var authViewModel: AuthViewModel @StateObject private var chatListViewModel: ChatListViewModel public init(store: SessionStore) { let authViewModel = AuthViewModel(store: store) let chatListViewModel = ChatListViewModel(bridge: store.bridge) _store = StateObject(wrappedValue: store) _authViewModel = StateObject(wrappedValue: authViewModel) _chatListViewModel = StateObject(wrappedValue: chatListViewModel) } public var body: some View { Group { switch store.authState { case .ready: ChatListView( viewModel: chatListViewModel, bridge: store.bridge, networkState: store.networkState, typingState: store.typingState ) default: AuthView(state: store.authState, viewModel: authViewModel) } } .task { await store.refreshAuthState() await store.refreshNetworkState() } } } public struct AuthView: View { public var state: AuthState @ObservedObject public var viewModel: AuthViewModel public init(state: AuthState, viewModel: AuthViewModel) { self.state = state self.viewModel = viewModel } public var body: some View { VStack(spacing: 16) { Text("Telegram") .font(.largeTitle) .fontWeight(.semibold) authField Button(action: { Task { await viewModel.submitCurrentStep() } }) { Text(buttonTitle) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .disabled(viewModel.isLoading || !canSubmit) if let errorMessage = viewModel.errorMessage { Text(errorMessage) .font(.footnote) .foregroundStyle(.red) } } .padding() } @ViewBuilder private var authField: some View { switch state { case .waitPhoneNumber, .waitTdlibParameters: TextField("Phone number", text: $viewModel.phone) .textContentType(.telephoneNumber) .textFieldStyle(.roundedBorder) case .waitCode: TextField("Code", text: $viewModel.code) .textContentType(.oneTimeCode) .textFieldStyle(.roundedBorder) case .waitPassword: SecureField("Password", text: $viewModel.password) .textContentType(.password) .textFieldStyle(.roundedBorder) case .ready: Text("Ready") case .closed: Text("Session closed") case let .error(message): Text(message) .foregroundStyle(.red) } } private var buttonTitle: String { switch state { case .waitPhoneNumber, .waitTdlibParameters: "Continue" case .waitCode: "Verify" case .waitPassword: "Unlock" default: "Continue" } } private var canSubmit: Bool { switch state { case .waitPhoneNumber, .waitTdlibParameters: !viewModel.phone.isEmpty case .waitCode: !viewModel.code.isEmpty case .waitPassword: !viewModel.password.isEmpty default: false } } } public struct ChatListView: View { @ObservedObject public var viewModel: ChatListViewModel public let bridge: SessionBridge public var networkState: NetworkState public var typingState: TypingState @State private var selectedChat: ChatSummary? @State private var showsAccountSwitcher = false public init( viewModel: ChatListViewModel, bridge: SessionBridge, networkState: NetworkState = .ready, typingState: TypingState = .idle ) { self.viewModel = viewModel self.bridge = bridge self.networkState = networkState self.typingState = typingState } public var body: some View { NavigationSplitView { VStack(spacing: 0) { List(selection: $selectedChat) { ForEach(viewModel.filteredChats) { chat in NavigationLink(value: chat) { ChatRow(chat: chat) } } } ChatListStatusBar(networkState: networkState, typingState: typingState) } .navigationTitle("Chats") .searchable(text: $viewModel.searchText) .toolbar { ToolbarItem { Button("Accounts") { showsAccountSwitcher = true } } ToolbarItem { folderMenu } } .sheet(isPresented: $showsAccountSwitcher) { AccountSwitcherView() } .task { await viewModel.load() } } detail: { if let selectedChat { ChatDetailView(viewModel: ChatViewModel(chat: selectedChat, bridge: bridge), bridge: bridge) } else { Text("Select a chat") } } } private var folderMenu: some View { Menu("Folders") { Button("All") { viewModel.selectedFolderId = nil Task { await viewModel.load() } } ForEach(viewModel.folders) { folder in Button(folder.name) { viewModel.selectedFolderId = folder.id Task { await viewModel.load() } } } } } } public struct ChatListStatusBar: View { public var networkState: NetworkState public var typingState: TypingState public init(networkState: NetworkState, typingState: TypingState) { self.networkState = networkState self.typingState = typingState } public var body: some View { HStack(spacing: 8) { Image(systemName: networkIconName) .foregroundStyle(networkState == .ready ? .green : .orange) Text(statusText) .font(.footnote) .lineLimit(1) Spacer() } .padding(.horizontal, 12) .padding(.vertical, 8) .background(.bar) } private var networkIconName: String { switch networkState { case .ready: "checkmark.circle.fill" case .waitingForNetwork: "wifi.slash" case .connectingToProxy: "shield.lefthalf.filled" case .connecting: "antenna.radiowaves.left.and.right" case .updating: "arrow.triangle.2.circlepath" } } private var statusText: String { switch typingState { case let .typing(_, _, text) where !text.isEmpty: text case .typing: "Typing" case .idle: networkText } } private var networkText: String { switch networkState { case .ready: "Online" case .waitingForNetwork: "Waiting for network" case .connectingToProxy: "Connecting to proxy" case .connecting: "Connecting" case .updating: "Updating" } } } public struct ChatRow: View { public var chat: ChatSummary public init(chat: ChatSummary) { self.chat = chat } public var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(chat.title) .font(.headline) .lineLimit(1) if chat.isPinned { Image(systemName: "pin.fill") .font(.caption) } if chat.isMuted { Image(systemName: "bell.slash") .font(.caption) .foregroundStyle(.secondary) } Spacer() if chat.unreadMentionCount > 0 { Text("@\(chat.unreadMentionCount)") .font(.caption) .padding(.horizontal, 7) .padding(.vertical, 3) .background(.orange, in: Capsule()) .foregroundStyle(.white) } if chat.unreadCount > 0 { Text("\(chat.unreadCount)") .font(.caption) .padding(.horizontal, 7) .padding(.vertical, 3) .background(.blue, in: Capsule()) .foregroundStyle(.white) } } HStack(spacing: 4) { if chat.draft != nil { Text("Draft") .fontWeight(.semibold) } Text(chat.draft?.text ?? chat.lastMessage) } .font(.subheadline) .foregroundStyle(chat.draft == nil ? Color.secondary : Color.red) .lineLimit(2) } .padding(.vertical, 4) } } public struct ChatDetailView: View { @StateObject public var viewModel: ChatViewModel public let bridge: SessionBridge public let clipboard: ClipboardWriting @StateObject private var profileViewModel: ProfileViewModel @State private var showsProfile = false @State private var editingMessage: Message? @State private var editedText = "" @State private var deleteCandidate: Message? @State private var forwardCandidate: Message? @State private var forwardChatIdText = "" public init( viewModel: ChatViewModel, bridge: SessionBridge, clipboard: ClipboardWriting = SystemClipboardWriter() ) { _viewModel = StateObject(wrappedValue: viewModel) self.bridge = bridge self.clipboard = clipboard _profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge)) } public var body: some View { VStack(spacing: 0) { List { if !viewModel.searchText.isEmpty { Section("Search") { if viewModel.searchResults.isEmpty { Text("No results") .foregroundStyle(.secondary) } else { ForEach(viewModel.searchResults) { message in MessageRow(message: message) .listRowSeparator(.hidden) } } } } Section { ForEach(viewModel.messages) { message in MessageRow(message: message) .contextMenu { Button { viewModel.beginReply(to: message) } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } Button { editingMessage = message editedText = message.text } label: { Label("Edit", systemImage: "pencil") } Button { forwardCandidate = message forwardChatIdText = "" } label: { Label("Forward", systemImage: "arrowshape.turn.up.forward") } Button { Task { await viewModel.react(message: message, reaction: "👍") } } label: { Label("React", systemImage: "face.smiling") } Button { Task { await viewModel.copyPayload(for: message) if let payload = viewModel.copiedPayload { await clipboard.write(text: payload) } } } label: { Label("Copy", systemImage: "doc.on.doc") } Button(role: .destructive) { deleteCandidate = message } label: { Label("Delete", systemImage: "trash") } } .listRowSeparator(.hidden) } } } ComposeBar( text: $viewModel.composeText, replyTo: viewModel.replyTo, cancelReply: { viewModel.cancelReply() } ) { Task { await viewModel.send() } } } .navigationTitle(viewModel.chat.title) .searchable(text: $viewModel.searchText) .onSubmit(of: .search) { Task { await viewModel.search() } } .toolbar { Button("Profile") { showsProfile = true Task { await profileViewModel.load(chatId: viewModel.chat.id) } } } .sheet(isPresented: $showsProfile) { ProfileView(viewModel: profileViewModel) } .alert("Edit Message", isPresented: editAlertBinding) { TextField("Message", text: $editedText) Button("Save") { if let editingMessage { Task { await viewModel.edit(message: editingMessage, text: editedText) } } editingMessage = nil } Button("Cancel", role: .cancel) { editingMessage = nil } } .alert("Delete Message", isPresented: deleteAlertBinding) { Button("Delete", role: .destructive) { if let deleteCandidate { Task { await viewModel.delete(message: deleteCandidate) } } deleteCandidate = nil } Button("Cancel", role: .cancel) { deleteCandidate = nil } } .alert("Forward Message", isPresented: forwardAlertBinding) { TextField("Chat ID", text: $forwardChatIdText) Button("Forward") { if let forwardCandidate, let chatId = Int64(forwardChatIdText) { Task { await viewModel.forward(message: forwardCandidate, to: chatId) } } forwardCandidate = nil } Button("Cancel", role: .cancel) { forwardCandidate = nil } } .task { await viewModel.load() } } private var editAlertBinding: Binding { Binding( get: { editingMessage != nil }, set: { isPresented in if !isPresented { editingMessage = nil } } ) } private var deleteAlertBinding: Binding { Binding( get: { deleteCandidate != nil }, set: { isPresented in if !isPresented { deleteCandidate = nil } } ) } private var forwardAlertBinding: Binding { Binding( get: { forwardCandidate != nil }, set: { isPresented in if !isPresented { forwardCandidate = nil } } ) } } public struct MessageRow: View { public var message: Message public init(message: Message) { self.message = message } public var body: some View { HStack { if message.isOutgoing { Spacer(minLength: 48) } VStack(alignment: .leading, spacing: 5) { if !message.isOutgoing { Text(message.senderName) .font(.caption) .foregroundStyle(.secondary) } if let replyText = message.replyText { Text(replyText) .font(.caption) .foregroundStyle(.secondary) .padding(.leading, 6) } if let forwardSenderName = message.forwardSenderName { Label(forwardSenderName, systemImage: "arrowshape.turn.up.forward") .font(.caption) .foregroundStyle(.secondary) } Text(renderedText) .textSelection(.enabled) if !message.reactions.isEmpty { Text(message.reactions.map(\.emoji).joined(separator: " ")) .font(.caption) } if message.editDate != nil || message.isOutgoing { HStack(spacing: 6) { if message.editDate != nil { Text("edited") } if message.isOutgoing { Image(systemName: message.isRead ? "checkmark.circle.fill" : "checkmark.circle") } } .font(.caption2) .foregroundStyle(.secondary) } } .padding(10) .background(message.isOutgoing ? Color.blue.opacity(0.16) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) if !message.isOutgoing { Spacer(minLength: 48) } } } private var renderedText: AttributedString { ( try? AttributedString( markdown: message.text, options: AttributedString.MarkdownParsingOptions( interpretedSyntax: .inlineOnlyPreservingWhitespace ) ) ) ?? AttributedString(message.text) } } public struct ComposeBar: View { @Binding public var text: String public var replyTo: Message? public var cancelReply: () -> Void public var send: () -> Void public init( text: Binding, replyTo: Message? = nil, cancelReply: @escaping () -> Void = {}, send: @escaping () -> Void ) { _text = text self.replyTo = replyTo self.cancelReply = cancelReply self.send = send } public var body: some View { VStack(spacing: 8) { if let replyTo { HStack { VStack(alignment: .leading, spacing: 2) { Text(replyTo.senderName) .font(.caption) .foregroundStyle(.secondary) Text(replyTo.text) .font(.footnote) .lineLimit(1) } Spacer() Button(action: cancelReply) { Image(systemName: "xmark.circle.fill") } .buttonStyle(.plain) } .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) } HStack(spacing: 10) { if !text.isEmpty { Button { text = "" } label: { Image(systemName: "xmark.circle.fill") } .buttonStyle(.plain) } TextField("Message", text: $text, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...5) Button { send() } label: { Image(systemName: "paperplane.fill") } .buttonStyle(.borderedProminent) .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .padding() .background(.bar) } } public struct ProfileView: View { @ObservedObject public var viewModel: ProfileViewModel public init(viewModel: ProfileViewModel) { self.viewModel = viewModel } public var body: some View { NavigationStack { List { if let profile = viewModel.profile { Section { Text(profile.title) .font(.title2) if let username = profile.username { Text("@\(username)") .foregroundStyle(.secondary) } if let bio = profile.bio { Text(bio) } } if let memberCount = profile.memberCount { Section { Text("\(memberCount) members") } } } else { ProgressView() } } .navigationTitle("Profile") } } } public struct AccountSwitcherView: View { public init() {} public var body: some View { NavigationStack { List { Section { Label("Default", systemImage: "person.crop.circle") Label("Add account", systemImage: "plus.circle") } } .navigationTitle("Accounts") } } }