Add SwiftUI iOS app shell
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ credentials
|
||||
# Commit snapshots, but not the .new files
|
||||
tests/**/*.snap.new
|
||||
*.snap.new
|
||||
apps/ios/TeleTuiIOS/.build/
|
||||
|
||||
27
apps/ios/TeleTuiIOS/Package.swift
Normal file
27
apps/ios/TeleTuiIOS/Package.swift
Normal file
@@ -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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
22
apps/ios/TeleTuiIOS/README.md
Normal file
22
apps/ios/TeleTuiIOS/README.md
Normal file
@@ -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
|
||||
```
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
128
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift
Normal file
128
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
168
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift
Normal file
168
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift
Normal file
@@ -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
|
||||
}
|
||||
38
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift
Normal file
38
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
203
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift
Normal file
203
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
370
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift
Normal file
370
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift
Normal file
@@ -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<String>, 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
80
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift
Normal file
80
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user