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
|
# Commit snapshots, but not the .new files
|
||||||
tests/**/*.snap.new
|
tests/**/*.snap.new
|
||||||
*.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