From d68d68aeda95cfd0a036f50aa486015807407e57 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Wed, 20 May 2026 15:43:07 +0300 Subject: [PATCH] Add SwiftUI iOS app shell --- .gitignore | 1 + apps/ios/TeleTuiIOS/Package.swift | 27 ++ apps/ios/TeleTuiIOS/README.md | 22 ++ .../Sources/TeleTuiIOSApp/TeleTuiIOSApp.swift | 21 + .../Sources/TeleTuiIOSCore/Bridge.swift | 128 ++++++ .../Sources/TeleTuiIOSCore/Models.swift | 168 ++++++++ .../Sources/TeleTuiIOSCore/Storage.swift | 38 ++ .../Sources/TeleTuiIOSCore/ViewModels.swift | 203 ++++++++++ .../Sources/TeleTuiIOSCore/Views.swift | 370 ++++++++++++++++++ .../Sources/TeleTuiIOSSmokeTests/main.swift | 80 ++++ 10 files changed, 1058 insertions(+) create mode 100644 apps/ios/TeleTuiIOS/Package.swift create mode 100644 apps/ios/TeleTuiIOS/README.md create mode 100644 apps/ios/TeleTuiIOS/Sources/TeleTuiIOSApp/TeleTuiIOSApp.swift create mode 100644 apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift create mode 100644 apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift create mode 100644 apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift create mode 100644 apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift create mode 100644 apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift create mode 100644 apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift diff --git a/.gitignore b/.gitignore index c4af13b..99b8c4b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ credentials # Commit snapshots, but not the .new files tests/**/*.snap.new *.snap.new +apps/ios/TeleTuiIOS/.build/ diff --git a/apps/ios/TeleTuiIOS/Package.swift b/apps/ios/TeleTuiIOS/Package.swift new file mode 100644 index 0000000..3216219 --- /dev/null +++ b/apps/ios/TeleTuiIOS/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "TeleTuiIOS", + platforms: [ + .iOS(.v17), + .macOS(.v14), + ], + products: [ + .library(name: "TeleTuiIOSCore", targets: ["TeleTuiIOSCore"]), + .executable(name: "TeleTuiIOSApp", targets: ["TeleTuiIOSApp"]), + .executable(name: "TeleTuiIOSSmokeTests", targets: ["TeleTuiIOSSmokeTests"]), + ], + targets: [ + .target(name: "TeleTuiIOSCore"), + .executableTarget( + name: "TeleTuiIOSApp", + dependencies: ["TeleTuiIOSCore"] + ), + .executableTarget( + name: "TeleTuiIOSSmokeTests", + dependencies: ["TeleTuiIOSCore"] + ), + ] +) diff --git a/apps/ios/TeleTuiIOS/README.md b/apps/ios/TeleTuiIOS/README.md new file mode 100644 index 0000000..e7ff9ed --- /dev/null +++ b/apps/ios/TeleTuiIOS/README.md @@ -0,0 +1,22 @@ +# TeleTuiIOS + +Native SwiftUI shell for the iOS client. + +Current scope: + +- SwiftUI + MVVM app shell backed by a deterministic fake bridge. +- Auth, chat list, folder selector, chat detail, compose bar, profile sheet, and account switcher shell. +- iOS-oriented storage boundaries: Keychain-shaped credential API and Application Support account paths. + +Build and smoke-test the portable shell: + +```bash +cd apps/ios/TeleTuiIOS +swift run TeleTuiIOSSmokeTests +``` + +Simulator/device build is currently blocked on this machine because `xcodebuild -version` fails with: + +```text +xcode-select: error: tool 'xcodebuild' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance +``` diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSApp/TeleTuiIOSApp.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSApp/TeleTuiIOSApp.swift new file mode 100644 index 0000000..4c5b225 --- /dev/null +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSApp/TeleTuiIOSApp.swift @@ -0,0 +1,21 @@ +import SwiftUI +import TeleTuiIOSCore + +@main +struct TeleTuiIOSApp: App { + var body: some Scene { + WindowGroup { + RootView(store: makeStore()) + } + } + + private func makeStore() -> SessionStore { + let paths = AppStoragePaths() + let account = Account( + id: "fake", + displayName: "Fake", + databasePath: paths.databasePath(for: "fake") + ) + return SessionStore(account: account, bridge: FakeSessionBridge(auth: .ready)) + } +} diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift new file mode 100644 index 0000000..7f12c12 --- /dev/null +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift @@ -0,0 +1,128 @@ +import Foundation + +public protocol SessionBridge: Sendable { + func authState() async throws -> AuthState + func pollEvents() async throws -> [SessionEvent] + func sendPhoneNumber(_ phone: String) async throws + func sendCode(_ code: String) async throws + func sendPassword(_ password: String) async throws + func loadFolders() async throws -> [Folder] + func loadChats(folderId: Int32?) async throws -> [ChatSummary] + func loadHistory(chatId: Int64) async throws -> [Message] + func openProfile(chatId: Int64) async throws -> Profile + func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message +} + +public actor FakeSessionBridge: SessionBridge { + private var auth: AuthState + private var chats: [ChatSummary] + private var messages: [Int64: [Message]] + private var events: [SessionEvent] + private var nextMessageId: Int64 + + public init(auth: AuthState = .waitPhoneNumber) { + self.auth = auth + let saved = ChatSummary( + id: 1, + title: "Saved Messages", + username: "saved", + lastMessage: "Hello from fake TDLib", + unreadCount: 1, + isPinned: true + ) + let team = ChatSummary( + id: 2, + title: "iOS Team", + lastMessage: "Bridge smoke is green", + unreadMentionCount: 1, + folderIds: [0, 2], + isMuted: true, + draft: Draft(chatId: 2, text: "Follow up") + ) + self.chats = [saved, team] + self.messages = [ + 1: [ + Message(id: 1, chatId: 1, senderName: "Alice", text: "Hello from fake TDLib", isOutgoing: false, isRead: false) + ], + 2: [ + Message(id: 2, chatId: 2, senderName: "Mikhail", text: "Bridge smoke is green", isOutgoing: true) + ], + ] + self.events = [.chatListChanged([saved, team])] + self.nextMessageId = 3 + } + + public func authState() async throws -> AuthState { + auth + } + + public func pollEvents() async throws -> [SessionEvent] { + let drained = events + events.removeAll() + return drained + } + + public func sendPhoneNumber(_ phone: String) async throws { + auth = .waitCode + events.append(.authChanged(auth)) + } + + public func sendCode(_ code: String) async throws { + auth = .waitPassword + events.append(.authChanged(auth)) + } + + public func sendPassword(_ password: String) async throws { + auth = .ready + events.append(.authChanged(auth)) + } + + public func loadFolders() async throws -> [Folder] { + [Folder(id: 0, name: "All"), Folder(id: 2, name: "Work")] + } + + public func loadChats(folderId: Int32?) async throws -> [ChatSummary] { + let result = folderId.map { folderId in + chats.filter { $0.folderIds.contains(folderId) } + } ?? chats + events.append(.chatListChanged(result)) + return result + } + + public func loadHistory(chatId: Int64) async throws -> [Message] { + messages[chatId] ?? [] + } + + public func openProfile(chatId: Int64) async throws -> Profile { + let chat = chats.first { $0.id == chatId } + let profile = Profile( + chatId: chatId, + title: chat?.title ?? "Unknown", + username: chat?.username, + bio: chatId == 1 ? "Fake profile for the iOS app shell" : "Team chat", + isGroup: chatId != 1, + memberCount: chatId == 1 ? nil : 4 + ) + events.append(.profileLoaded(profile)) + return profile + } + + public func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message { + let message = Message( + id: nextMessageId, + chatId: chatId, + senderName: "Me", + text: text, + isOutgoing: true, + replyText: replyToMessageId.map { "Reply to #\($0)" } + ) + nextMessageId += 1 + messages[chatId, default: []].append(message) + if let index = chats.firstIndex(where: { $0.id == chatId }) { + chats[index].lastMessage = text + chats[index].draft = nil + } + events.append(.messageAdded(chatId, message)) + return message + } +} diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift new file mode 100644 index 0000000..854b4ff --- /dev/null +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift @@ -0,0 +1,168 @@ +import Foundation + +public struct Account: Identifiable, Equatable, Sendable { + public var id: String + public var displayName: String + public var databasePath: URL + + public init(id: String, displayName: String, databasePath: URL) { + self.id = id + self.displayName = displayName + self.databasePath = databasePath + } +} + +public enum AuthState: Equatable, Sendable { + case waitTdlibParameters + case waitPhoneNumber + case waitCode + case waitPassword + case ready + case closed + case error(String) +} + +public struct Folder: Identifiable, Equatable, Sendable { + public var id: Int32 + public var name: String + + public init(id: Int32, name: String) { + self.id = id + self.name = name + } +} + +public struct Draft: Hashable, Sendable { + public var chatId: Int64 + public var text: String + + public init(chatId: Int64, text: String) { + self.chatId = chatId + self.text = text + } +} + +public struct ChatSummary: Identifiable, Hashable, Sendable { + public var id: Int64 + public var title: String + public var username: String? + public var lastMessage: String + public var unreadCount: Int32 + public var unreadMentionCount: Int32 + public var isPinned: Bool + public var folderIds: [Int32] + public var isMuted: Bool + public var draft: Draft? + + public init( + id: Int64, + title: String, + username: String? = nil, + lastMessage: String, + unreadCount: Int32 = 0, + unreadMentionCount: Int32 = 0, + isPinned: Bool = false, + folderIds: [Int32] = [0], + isMuted: Bool = false, + draft: Draft? = nil + ) { + self.id = id + self.title = title + self.username = username + self.lastMessage = lastMessage + self.unreadCount = unreadCount + self.unreadMentionCount = unreadMentionCount + self.isPinned = isPinned + self.folderIds = folderIds + self.isMuted = isMuted + self.draft = draft + } +} + +public struct Reaction: Equatable, Sendable { + public var emoji: String + public var count: Int32 + public var isChosen: Bool + + public init(emoji: String, count: Int32, isChosen: Bool) { + self.emoji = emoji + self.count = count + self.isChosen = isChosen + } +} + +public struct Message: Identifiable, Equatable, Sendable { + public var id: Int64 + public var chatId: Int64 + public var senderName: String + public var text: String + public var isOutgoing: Bool + public var isRead: Bool + public var editDate: Int32? + public var replyText: String? + public var forwardSenderName: String? + public var reactions: [Reaction] + + public init( + id: Int64, + chatId: Int64, + senderName: String, + text: String, + isOutgoing: Bool, + isRead: Bool = true, + editDate: Int32? = nil, + replyText: String? = nil, + forwardSenderName: String? = nil, + reactions: [Reaction] = [] + ) { + self.id = id + self.chatId = chatId + self.senderName = senderName + self.text = text + self.isOutgoing = isOutgoing + self.isRead = isRead + self.editDate = editDate + self.replyText = replyText + self.forwardSenderName = forwardSenderName + self.reactions = reactions + } +} + +public struct Profile: Equatable, Sendable { + public var chatId: Int64 + public var title: String + public var username: String? + public var bio: String? + public var isGroup: Bool + public var memberCount: Int32? + + public init( + chatId: Int64, + title: String, + username: String? = nil, + bio: String? = nil, + isGroup: Bool = false, + memberCount: Int32? = nil + ) { + self.chatId = chatId + self.title = title + self.username = username + self.bio = bio + self.isGroup = isGroup + self.memberCount = memberCount + } +} + +public enum SessionEvent: Equatable, Sendable { + case authChanged(AuthState) + case chatListChanged([ChatSummary]) + case messageAdded(Int64, Message) + case profileLoaded(Profile) +} + +public enum NetworkState: Equatable, Sendable { + case waitingForNetwork + case connecting + case updating + case ready +} diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift new file mode 100644 index 0000000..76e89e8 --- /dev/null +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct AppStoragePaths: Sendable { + public var root: URL + + public init(root: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("TeleTuiIOS")) { + self.root = root + } + + public func databasePath(for accountId: String) -> URL { + root + .appendingPathComponent("Accounts", isDirectory: true) + .appendingPathComponent(accountId, isDirectory: true) + .appendingPathComponent("tdlib", isDirectory: true) + } +} + +public protocol CredentialStore: Sendable { + func save(account: Account) async throws + func loadAccounts() async throws -> [Account] +} + +public actor InMemoryCredentialStore: CredentialStore { + private var accounts: [Account] + + public init(accounts: [Account] = []) { + self.accounts = accounts + } + + public func save(account: Account) async throws { + accounts.removeAll { $0.id == account.id } + accounts.append(account) + } + + public func loadAccounts() async throws -> [Account] { + accounts + } +} diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift new file mode 100644 index 0000000..0514a6c --- /dev/null +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift @@ -0,0 +1,203 @@ +import Foundation +import Combine + +@MainActor +public final class SessionStore: ObservableObject { + @Published public private(set) var account: Account + @Published public private(set) var authState: AuthState = .waitTdlibParameters + @Published public private(set) var networkState: NetworkState = .ready + @Published public private(set) var errorMessage: String? + + public let bridge: SessionBridge + + public init(account: Account, bridge: SessionBridge) { + self.account = account + self.bridge = bridge + } + + public func refreshAuthState() async { + do { + authState = try await bridge.authState() + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + public func apply(events: [SessionEvent]) { + for event in events { + if case let .authChanged(state) = event { + authState = state + } + } + } +} + +@MainActor +public final class AuthViewModel: ObservableObject { + @Published public var phone = "" + @Published public var code = "" + @Published public var password = "" + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + + private let store: SessionStore + + public init(store: SessionStore) { + self.store = store + } + + public func submitCurrentStep() async { + isLoading = true + defer { isLoading = false } + + do { + switch store.authState { + case .waitPhoneNumber: + try await store.bridge.sendPhoneNumber(phone) + case .waitCode: + try await store.bridge.sendCode(code) + case .waitPassword: + try await store.bridge.sendPassword(password) + default: + break + } + let events = try await store.bridge.pollEvents() + store.apply(events: events) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +@MainActor +public final class ChatListViewModel: ObservableObject { + @Published public private(set) var folders: [Folder] = [] + @Published public private(set) var chats: [ChatSummary] = [] + @Published public var selectedFolderId: Int32? + @Published public var searchText = "" + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + + private let bridge: SessionBridge + + public init(bridge: SessionBridge) { + self.bridge = bridge + } + + public var filteredChats: [ChatSummary] { + guard !searchText.isEmpty else { + return chats + } + return chats.filter { chat in + chat.title.localizedCaseInsensitiveContains(searchText) + || (chat.username?.localizedCaseInsensitiveContains(searchText) ?? false) + } + } + + public func load() async { + isLoading = true + defer { isLoading = false } + + do { + folders = try await bridge.loadFolders() + chats = try await bridge.loadChats(folderId: selectedFolderId) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +@MainActor +public final class ChatViewModel: ObservableObject { + @Published public private(set) var chat: ChatSummary + @Published public private(set) var messages: [Message] = [] + @Published public var composeText: String + @Published public var replyTo: Message? + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + + private let bridge: SessionBridge + + public init(chat: ChatSummary, bridge: SessionBridge) { + self.chat = chat + self.bridge = bridge + self.composeText = chat.draft?.text ?? "" + } + + public func load() async { + isLoading = true + defer { isLoading = false } + + do { + messages = try await bridge.loadHistory(chatId: chat.id) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + public func send() async { + let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + return + } + + do { + let sent = try await bridge.sendMessage( + chatId: chat.id, + text: text, + replyToMessageId: replyTo?.id + ) + messages.append(sent) + composeText = "" + replyTo = nil + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +@MainActor +public final class ProfileViewModel: ObservableObject { + @Published public private(set) var profile: Profile? + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + + private let bridge: SessionBridge + + public init(bridge: SessionBridge) { + self.bridge = bridge + } + + public func load(chatId: Int64) async { + isLoading = true + defer { isLoading = false } + + do { + profile = try await bridge.openProfile(chatId: chatId) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +@MainActor +public final class MediaViewModel: ObservableObject { + @Published public private(set) var activePhotoPath: String? + @Published public private(set) var activeVoicePath: String? + + public init() {} + + public func showPhoto(path: String) { + activePhotoPath = path + } + + public func showVoice(path: String) { + activeVoicePath = path + } +} diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift new file mode 100644 index 0000000..99344c1 --- /dev/null +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift @@ -0,0 +1,370 @@ +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) + default: + AuthView(state: store.authState, viewModel: authViewModel) + } + } + .task { + await store.refreshAuthState() + } + } +} + +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 + @State private var selectedChat: ChatSummary? + @State private var showsAccountSwitcher = false + + public init(viewModel: ChatListViewModel, bridge: SessionBridge) { + self.viewModel = viewModel + self.bridge = bridge + } + + public var body: some View { + NavigationSplitView { + List(selection: $selectedChat) { + ForEach(viewModel.filteredChats) { chat in + NavigationLink(value: chat) { + ChatRow(chat: chat) + } + } + } + .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 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) + } + Spacer() + if chat.unreadCount > 0 { + Text("\(chat.unreadCount)") + .font(.caption) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(.blue, in: Capsule()) + .foregroundStyle(.white) + } + } + 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 + @StateObject private var profileViewModel: ProfileViewModel + @State private var showsProfile = false + + public init(viewModel: ChatViewModel, bridge: SessionBridge) { + _viewModel = StateObject(wrappedValue: viewModel) + self.bridge = bridge + _profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge)) + } + + public var body: some View { + VStack(spacing: 0) { + List(viewModel.messages) { message in + MessageRow(message: message) + .listRowSeparator(.hidden) + } + ComposeBar(text: $viewModel.composeText) { + Task { await viewModel.send() } + } + } + .navigationTitle(viewModel.chat.title) + .toolbar { + Button("Profile") { + showsProfile = true + Task { await profileViewModel.load(chatId: viewModel.chat.id) } + } + } + .sheet(isPresented: $showsProfile) { + ProfileView(viewModel: profileViewModel) + } + .task { + await viewModel.load() + } + } +} + +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) + } + Text(message.text) + .textSelection(.enabled) + if !message.reactions.isEmpty { + Text(message.reactions.map(\.emoji).joined(separator: " ")) + .font(.caption) + } + } + .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) + } + } + } +} + +public struct ComposeBar: View { + @Binding public var text: String + public var send: () -> Void + + public init(text: Binding, send: @escaping () -> Void) { + _text = text + self.send = send + } + + public var body: some View { + HStack(spacing: 10) { + TextField("Message", text: $text, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...5) + Button(action: send) { + 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") + } + } +} diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift new file mode 100644 index 0000000..94f751e --- /dev/null +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift @@ -0,0 +1,80 @@ +import Foundation +import TeleTuiIOSCore + +@main +struct TeleTuiIOSSmokeTests { + static func main() async throws { + try await authFlowMatchesAllInteractiveStates() + try await chatListLoadsDeterministicFakeDataAndFilters() + try await chatDetailLoadsAndSendsMessage() + try await profileLoadsFromSelectedChat() + appStorageUsesApplicationSupportStyleAccountPaths() + print("TeleTuiIOS smoke tests passed") + } + + @MainActor + private static func authFlowMatchesAllInteractiveStates() async throws { + let account = Account(id: "fake", displayName: "Fake", databasePath: URL(fileURLWithPath: "/tmp/fake")) + let store = SessionStore(account: account, bridge: FakeSessionBridge()) + let viewModel = AuthViewModel(store: store) + + await store.refreshAuthState() + precondition(store.authState == .waitPhoneNumber) + + viewModel.phone = "+10000000000" + await viewModel.submitCurrentStep() + precondition(store.authState == .waitCode) + + viewModel.code = "12345" + await viewModel.submitCurrentStep() + precondition(store.authState == .waitPassword) + + viewModel.password = "secret" + await viewModel.submitCurrentStep() + precondition(store.authState == .ready) + } + + @MainActor + private static func chatListLoadsDeterministicFakeDataAndFilters() async throws { + let bridge = FakeSessionBridge(auth: .ready) + let viewModel = ChatListViewModel(bridge: bridge) + + await viewModel.load() + precondition(viewModel.chats.map(\.title) == ["Saved Messages", "iOS Team"]) + + viewModel.searchText = "team" + precondition(viewModel.filteredChats.map(\.title) == ["iOS Team"]) + } + + @MainActor + private static func chatDetailLoadsAndSendsMessage() async throws { + let bridge = FakeSessionBridge(auth: .ready) + let chat = try await bridge.loadChats(folderId: nil)[0] + let viewModel = ChatViewModel(chat: chat, bridge: bridge) + + await viewModel.load() + precondition(viewModel.messages.count == 1) + + viewModel.composeText = "Hi from SwiftUI" + await viewModel.send() + precondition(viewModel.messages.last?.text == "Hi from SwiftUI") + precondition(viewModel.composeText.isEmpty) + } + + @MainActor + private static func profileLoadsFromSelectedChat() async throws { + let bridge = FakeSessionBridge(auth: .ready) + let viewModel = ProfileViewModel(bridge: bridge) + + await viewModel.load(chatId: 1) + precondition(viewModel.profile?.title == "Saved Messages") + precondition(viewModel.profile?.username == "saved") + } + + private static func appStorageUsesApplicationSupportStyleAccountPaths() { + let root = URL(fileURLWithPath: "/tmp/TeleTuiIOS") + let paths = AppStoragePaths(root: root) + + precondition(paths.databasePath(for: "work").path == "/tmp/TeleTuiIOS/Accounts/work/tdlib") + } +}