Compare commits

..

40 Commits

Author SHA1 Message Date
8f65fe39ab Merge pull request 'feat/ios-core-session-api' (#32) from feat/ios-core-session-api into main
Some checks failed
iOS and Rust / rust (push) Has been cancelled
iOS and Rust / ios-shell (push) Has been cancelled
Reviewed-on: #32
2026-05-21 13:24:31 +00:00
Mikhail Kilin
d48a03f93d Add iOS AVPlayer voice backend
Some checks failed
iOS and Rust / rust (pull_request) Has been cancelled
iOS and Rust / ios-shell (pull_request) Has been cancelled
2026-05-21 15:57:23 +03:00
Mikhail Kilin
c12f9f9b78 Add iOS notification scheduler 2026-05-21 15:55:54 +03:00
Mikhail Kilin
5a32ee0a4c Add iOS reaction picker 2026-05-21 15:53:26 +03:00
Mikhail Kilin
782f08e00e Add iOS profile leave flow 2026-05-21 15:52:25 +03:00
Mikhail Kilin
ec74961677 Add iOS media placeholders 2026-05-21 15:50:14 +03:00
Mikhail Kilin
508db79c34 Add iOS message date grouping 2026-05-21 15:45:48 +03:00
Mikhail Kilin
da41e1ed91 Add iOS pinned messages bar 2026-05-21 15:39:16 +03:00
Mikhail Kilin
419f409d98 Render iOS message markdown 2026-05-21 15:37:04 +03:00
Mikhail Kilin
a0413f23b3 Add iOS chat list status indicators 2026-05-21 15:35:16 +03:00
Mikhail Kilin
6062c1b503 Add iOS message action controls 2026-05-21 15:33:18 +03:00
Mikhail Kilin
217328505c Wire local TDLib into iOS FFI build 2026-05-21 15:27:59 +03:00
Mikhail Kilin
aec3678bd6 Add CI typecheck for iOS UniFFI app bridge 2026-05-21 00:48:30 +03:00
Mikhail Kilin
75cd319f53 Add iOS session bridge factory 2026-05-21 00:47:21 +03:00
Mikhail Kilin
f7abd1dba0 Expose leave chat to iOS bridge 2026-05-21 00:45:39 +03:00
Mikhail Kilin
928a5aeda2 Preserve typing events in iOS FFI 2026-05-21 00:41:18 +03:00
Mikhail Kilin
b3b02835b6 Expose network state to iOS bridge 2026-05-21 00:36:08 +03:00
Mikhail Kilin
3e67e0d1b8 Expose draft updates to iOS bridge 2026-05-21 00:33:05 +03:00
Mikhail Kilin
892582df67 Wire iOS media downloads through session bridge 2026-05-21 00:29:47 +03:00
Mikhail Kilin
4fd2a18ed9 Expose pinned messages through iOS FFI 2026-05-21 00:23:33 +03:00
Mikhail Kilin
161cc343da Add Swift UniFFI session bridge adapter 2026-05-21 00:15:50 +03:00
Mikhail Kilin
9b4e277ce0 Add Swift FFI executable smoke 2026-05-21 00:12:08 +03:00
Mikhail Kilin
5ac63b84fb Expose iOS copy payload API 2026-05-20 23:56:18 +03:00
Mikhail Kilin
c83d2a1354 Add fake iOS FFI XCFramework build 2026-05-20 23:50:53 +03:00
Mikhail Kilin
7bde72f715 Add iOS simulator UI smoke check 2026-05-20 23:09:20 +03:00
Mikhail Kilin
f6b4b34ed4 Document iOS TDLib linking blocker 2026-05-20 23:04:03 +03:00
Mikhail Kilin
99ae5106ae Save iOS simulator smoke screenshot 2026-05-20 22:47:00 +03:00
Mikhail Kilin
10f4c3a84b Add iOS simulator launch scripts 2026-05-20 22:26:53 +03:00
Mikhail Kilin
6576a37198 Update iOS Xcode prerequisite checks 2026-05-20 16:14:17 +03:00
Mikhail Kilin
e5d0f2c064 Add iOS release readiness docs and CI 2026-05-20 15:52:22 +03:00
Mikhail Kilin
59050d0b5f Add iOS lifecycle hardening hooks 2026-05-20 15:51:15 +03:00
Mikhail Kilin
8bea159569 Add iOS platform service boundaries 2026-05-20 15:48:33 +03:00
Mikhail Kilin
593b19ba8e Expand iOS messaging shell actions 2026-05-20 15:45:17 +03:00
Mikhail Kilin
d68d68aeda Add SwiftUI iOS app shell 2026-05-20 15:43:07 +03:00
Mikhail Kilin
0878ba78df Add UniFFI iOS bridge crate 2026-05-20 14:04:45 +03:00
Mikhail Kilin
186f0edbb3 Add iOS-facing core session facade 2026-05-20 00:56:42 +03:00
Mikhail Kilin
eefac431e5 Split core and TUI crates 2026-05-20 00:31:18 +03:00
91a8700b8e Merge pull request 'feat/rafactor' (#31) from feat/rafactor into main
Reviewed-on: #31
2026-05-17 22:22:44 +00:00
Mikhail Kilin
913055dd96 Stabilize termwright e2e flow 2026-05-17 23:20:49 +03:00
Mikhail Kilin
ceca8ab67e Add visual TUI test coverage 2026-05-17 23:09:33 +03:00
294 changed files with 34900 additions and 318 deletions

44
.github/workflows/ios-rust.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: iOS and Rust
on:
push:
branches: [main]
pull_request:
jobs:
rust:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Format
run: cargo fmt -- --check
- name: Core check
run: cargo check -p tele-core
- name: Workspace clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: Workspace tests
run: cargo test --workspace --all-features
- name: Fake iOS FFI tests
run: cargo test -p tele-ios-ffi --no-default-features --features standalone-fake
- name: Swift FFI smoke
run: scripts/smoke-ios-ffi-swift.sh /tmp/tele-ios-ffi-swift-smoke
- name: Swift app UniFFI bridge typecheck
run: scripts/typecheck-ios-uniffi-app-bridge.sh /tmp/tele-ios-ffi-swift-smoke /tmp/tele-ios-ffi-app-typecheck-module-cache
- name: Generate iOS FFI bindings
run: scripts/generate-ios-ffi-bindings.sh /tmp/tele-ios-ffi
- name: Swift bindings typecheck
run: swiftc -typecheck -I /tmp/tele-ios-ffi/Headers /tmp/tele-ios-ffi/Swift/tele_ios_ffi.swift
- name: Build fake iOS FFI XCFramework
run: scripts/build-ios-fake-ffi-xcframework.sh /tmp/tele-ios-fake-ffi-xcframework
ios-shell:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Build SwiftUI app shell
working-directory: apps/ios/TeleTuiIOS
run: swift build --product TeleTuiIOSApp
- name: Run SwiftUI smoke tests
working-directory: apps/ios/TeleTuiIOS
run: swift run TeleTuiIOSSmokeTests

5
.gitignore vendored
View File

@@ -1,4 +1,8 @@
/target
/.build
/build
/apps/ios/TeleTuiIOS/BinaryArtifacts
/apps/ios/TeleTuiIOS/Generated
# TDLib session data (contains auth tokens - NEVER commit!)
/tdlib_data/
@@ -15,3 +19,4 @@ credentials
# Commit snapshots, but not the .new files
tests/**/*.snap.new
*.snap.new
apps/ios/TeleTuiIOS/.build/

1089
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,12 @@
[package]
name = "tele-tui"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
description = "Terminal UI for Telegram with Vim-style navigation"
license = "MIT"
repository = "https://github.com/your-username/tele-tui"
keywords = ["telegram", "tui", "terminal", "cli"]
categories = ["command-line-utilities"]
[workspace]
members = [
"crates/tele-core",
"crates/tele-ios-ffi",
"crates/tele-tui",
"tools/uniffi-bindgen-swift",
]
default-members = ["crates/tele-tui"]
resolver = "2"
[features]
default = ["clipboard", "url-open", "notifications", "images"]
clipboard = ["dep:arboard"]
url-open = ["dep:open"]
notifications = ["dep:notify-rust"]
images = ["dep:ratatui-image", "dep:image"]
[dependencies]
ratatui = "0.29"
crossterm = "0.28"
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
chrono = "0.4"
open = { version = "5.0", optional = true }
arboard = { version = "3.4", optional = true }
notify-rust = { version = "4.11", optional = true }
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
image = { version = "0.25", optional = true }
toml = "0.8"
dirs = "5.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22.1"
fs2 = "0.4"
[dev-dependencies]
insta = "1.34"
tokio-test = "0.4"
criterion = "0.5"
[[bench]]
name = "group_messages"
harness = false
[[bench]]
name = "formatting"
harness = false
[[bench]]
name = "format_markdown"
harness = false
[patch.crates-io]
tdlib-rs = { path = "crates/vendor/tdlib-rs" }

View File

@@ -27,9 +27,11 @@ cargo check
```bash
cargo fmt -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo check -p tele-core
cargo test -p tele-core
cargo check -p tele-tui --all-targets --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
git diff --check
```

View File

@@ -22,7 +22,7 @@
- Для голосовых сообщений нужен `ffplay` из ffmpeg.
```bash
cargo build --release
cargo build -p tele-tui --release
```
## Credentials
@@ -54,6 +54,10 @@ cargo run --release
cargo run --release -- --account work
```
`Cargo.toml` в корне - workspace manifest. По умолчанию `cargo run` и `cargo test`
работают с `crates/tele-tui`; переиспользуемая TDLib-логика лежит в
`crates/tele-core`.
Runtime-конфиг создаётся в `~/.config/tele-tui/config.toml`; пример лежит в [config.toml.example](config.toml.example).
## Документация
@@ -64,6 +68,7 @@ Runtime-конфиг создаётся в `~/.config/tele-tui/config.toml`; п
- [docs/HOTKEYS.md](docs/HOTKEYS.md) - горячие клавиши.
- [docs/PROJECT_STRUCTURE.md](docs/PROJECT_STRUCTURE.md) - карта подсистем.
- [docs/TDLIB_INTEGRATION.md](docs/TDLIB_INTEGRATION.md) - проектные заметки по TDLib.
- [docs/IOS_CORE_REUSE.md](docs/IOS_CORE_REUSE.md) - граница `tele-core` для будущего iOS-клиента.
## Лицензия

View File

@@ -0,0 +1,56 @@
// swift-tools-version: 6.0
import Foundation
import PackageDescription
let useLocalFfi = ProcessInfo.processInfo.environment["TELE_IOS_USE_LOCAL_FFI"] == "1"
let localFfiTargets: [Target] = useLocalFfi ? [
.binaryTarget(
name: "tele_ios_ffiFFI",
path: "BinaryArtifacts/tele_ios_ffi.xcframework"
),
.binaryTarget(
name: "tdjson",
path: "BinaryArtifacts/tdjson.xcframework"
),
.target(
name: "tele_ios_ffi",
dependencies: ["tele_ios_ffiFFI", "tdjson"],
path: "Generated/tele_ios_ffi/Sources/tele_ios_ffi"
),
] : []
let coreDependencies: [Target.Dependency] = useLocalFfi ? [
"tele_ios_ffi",
] : []
let coreSwiftSettings: [SwiftSetting] = useLocalFfi ? [
.define("TELE_IOS_USE_LOCAL_FFI"),
] : []
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",
dependencies: coreDependencies,
swiftSettings: coreSwiftSettings
),
.executableTarget(
name: "TeleTuiIOSApp",
dependencies: ["TeleTuiIOSCore"]
),
.executableTarget(
name: "TeleTuiIOSSmokeTests",
dependencies: ["TeleTuiIOSCore"]
),
] + localFfiTargets
)

View File

@@ -0,0 +1,47 @@
# 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
```
Verify local iOS tooling:
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/check-ios-prereqs.sh
```
Build the SwiftUI shell for iOS Simulator and package it as an installable `.app`:
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-simulator-app.sh
```
Launch the fake-backed app in the first available iPhone simulator:
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/run-ios-simulator-app.sh
```
Run the simulator launch plus screenshot sanity check:
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/smoke-ios-simulator-ui.sh
```
Build the app against the local real Rust/TDLib FFI artifacts:
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-real-ffi-xcframework.sh
TELE_IOS_USE_LOCAL_FFI=1 DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-simulator-app.sh
```

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,24 @@
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: SessionBridgeFactory.makeDefaultBridge(account: account)
)
}
}

View File

@@ -0,0 +1,266 @@
import Foundation
public protocol SessionBridge: Sendable {
func authState() async throws -> AuthState
func networkState() async throws -> NetworkState
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 searchMessages(chatId: Int64, query: String) async throws -> [Message]
func openProfile(chatId: Int64) async throws -> Profile
func leaveChat(chatId: Int64) async throws
func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message
func editMessage(chatId: Int64, messageId: Int64, text: String) async throws -> Message
func deleteMessages(chatId: Int64, messageIds: [Int64]) async throws
func forwardMessages(toChatId: Int64, fromChatId: Int64, messageIds: [Int64]) async throws
func react(chatId: Int64, messageId: Int64, reaction: String) async throws -> [Reaction]
func pinnedMessages(chatId: Int64) async throws -> [Message]
func copyPayload(chatId: Int64, messageId: Int64) async throws -> String
func setDraft(chatId: Int64, text: String) async throws
func downloadPhoto(fileId: Int32) async throws -> DownloadedFile
func downloadVoice(fileId: Int32) async throws -> DownloadedFile
}
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
private static let baseMessageDate: Int32 = 1_700_000_000
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",
date: Self.baseMessageDate,
media: .photo(PhotoMedia(fileId: 100, width: 1280, height: 720)),
isOutgoing: false,
isRead: false
)
],
2: [
Message(
id: 2,
chatId: 2,
senderName: "Mikhail",
text: "Bridge smoke is green",
date: Self.baseMessageDate + 60,
media: .voice(VoiceMedia(fileId: 200, duration: 12, mimeType: "audio/ogg")),
isOutgoing: true
)
],
]
self.events = [.chatListChanged([saved, team])]
self.nextMessageId = 3
}
public func authState() async throws -> AuthState {
auth
}
public func networkState() async throws -> NetworkState {
.ready
}
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 searchMessages(chatId: Int64, query: String) async throws -> [Message] {
guard !query.isEmpty else {
return messages[chatId] ?? []
}
return (messages[chatId] ?? []).filter {
$0.text.localizedCaseInsensitiveContains(query)
|| $0.senderName.localizedCaseInsensitiveContains(query)
}
}
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 leaveChat(chatId: Int64) async throws {
chats.removeAll { $0.id == chatId }
messages.removeValue(forKey: chatId)
events.append(.chatListChanged(chats))
}
public func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message {
let message = Message(
id: nextMessageId,
chatId: chatId,
senderName: "Me",
text: text,
date: Int32(Date().timeIntervalSince1970),
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
}
public func editMessage(chatId: Int64, messageId: Int64, text: String) async throws -> Message {
guard var chatMessages = messages[chatId],
let index = chatMessages.firstIndex(where: { $0.id == messageId })
else {
throw FakeBridgeError.messageNotFound
}
chatMessages[index].text = text
chatMessages[index].editDate = Int32(Date().timeIntervalSince1970)
messages[chatId] = chatMessages
return chatMessages[index]
}
public func deleteMessages(chatId: Int64, messageIds: [Int64]) async throws {
messages[chatId]?.removeAll { messageIds.contains($0.id) }
}
public func forwardMessages(toChatId: Int64, fromChatId: Int64, messageIds: [Int64]) async throws {
let sourceMessages = (messages[fromChatId] ?? []).filter { messageIds.contains($0.id) }
for source in sourceMessages {
let forwarded = Message(
id: nextMessageId,
chatId: toChatId,
senderName: "Me",
text: source.text,
date: Int32(Date().timeIntervalSince1970),
isOutgoing: true,
forwardSenderName: source.senderName
)
nextMessageId += 1
messages[toChatId, default: []].append(forwarded)
events.append(.messageAdded(toChatId, forwarded))
}
}
public func react(chatId: Int64, messageId: Int64, reaction: String) async throws -> [Reaction] {
guard var chatMessages = messages[chatId],
let index = chatMessages.firstIndex(where: { $0.id == messageId })
else {
throw FakeBridgeError.messageNotFound
}
if let reactionIndex = chatMessages[index].reactions.firstIndex(where: { $0.emoji == reaction }) {
chatMessages[index].reactions.remove(at: reactionIndex)
} else {
chatMessages[index].reactions.append(Reaction(emoji: reaction, count: 1, isChosen: true))
}
messages[chatId] = chatMessages
return chatMessages[index].reactions
}
public func pinnedMessages(chatId: Int64) async throws -> [Message] {
Array((messages[chatId] ?? []).prefix(1))
}
public func copyPayload(chatId: Int64, messageId: Int64) async throws -> String {
guard let message = messages[chatId]?.first(where: { $0.id == messageId }) else {
throw FakeBridgeError.messageNotFound
}
return message.text
}
public func setDraft(chatId: Int64, text: String) async throws {
let draft = Draft(chatId: chatId, text: text)
if let index = chats.firstIndex(where: { $0.id == chatId }) {
chats[index].draft = text.isEmpty ? nil : draft
}
events.append(.draftChanged(draft))
}
public func downloadPhoto(fileId: Int32) async throws -> DownloadedFile {
DownloadedFile(fileId: fileId, path: "/tmp/fake-photo-\(fileId).jpg")
}
public func downloadVoice(fileId: Int32) async throws -> DownloadedFile {
DownloadedFile(fileId: fileId, path: "/tmp/fake-voice-\(fileId).ogg")
}
}
public enum FakeBridgeError: LocalizedError {
case messageNotFound
public var errorDescription: String? {
switch self {
case .messageNotFound:
"Message not found"
}
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
public enum AppLifecycleState: Equatable, Sendable {
case foreground
case background
}
public struct ScopedSessionEvent: Equatable, Sendable {
public var accountId: String
public var generation: Int
public var event: SessionEvent
public init(accountId: String, generation: Int, event: SessionEvent) {
self.accountId = accountId
self.generation = generation
self.event = event
}
}
@MainActor
public final class SessionLifecycleCoordinator: ObservableObject {
@Published public private(set) var lifecycleState: AppLifecycleState = .foreground
@Published public private(set) var activeAccountId: String
@Published public private(set) var generation = 0
public init(activeAccountId: String) {
self.activeAccountId = activeAccountId
}
public var shouldPollEvents: Bool {
lifecycleState == .foreground
}
public func enterBackground() {
lifecycleState = .background
}
public func enterForeground() {
lifecycleState = .foreground
}
public func switchAccount(to accountId: String) {
guard accountId != activeAccountId else {
return
}
activeAccountId = accountId
generation += 1
}
public func accepts(_ event: ScopedSessionEvent) -> Bool {
event.accountId == activeAccountId && event.generation == generation
}
}

View File

@@ -0,0 +1,255 @@
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 enum MediaDownloadState: Equatable, Sendable {
case notDownloaded
case downloading
case downloaded(path: String)
case error(String)
}
public struct PhotoMedia: Equatable, Sendable {
public var fileId: Int32
public var width: Int32
public var height: Int32
public var downloadState: MediaDownloadState
public init(
fileId: Int32,
width: Int32,
height: Int32,
downloadState: MediaDownloadState = .notDownloaded
) {
self.fileId = fileId
self.width = width
self.height = height
self.downloadState = downloadState
}
}
public struct VoiceMedia: Equatable, Sendable {
public var fileId: Int32
public var duration: Int32
public var mimeType: String?
public var waveform: String?
public var downloadState: MediaDownloadState
public init(
fileId: Int32,
duration: Int32,
mimeType: String? = nil,
waveform: String? = nil,
downloadState: MediaDownloadState = .notDownloaded
) {
self.fileId = fileId
self.duration = duration
self.mimeType = mimeType
self.waveform = waveform
self.downloadState = downloadState
}
}
public enum MessageMedia: Equatable, Sendable {
case photo(PhotoMedia)
case voice(VoiceMedia)
}
public struct Message: Identifiable, Equatable, Sendable {
public var id: Int64
public var chatId: Int64
public var senderName: String
public var text: String
public var date: Int32
public var mediaAlbumId: Int64?
public var media: MessageMedia?
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,
date: Int32 = 0,
mediaAlbumId: Int64? = nil,
media: MessageMedia? = nil,
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.date = date
self.mediaAlbumId = mediaAlbumId
self.media = media
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 struct DownloadedFile: Equatable, Sendable {
public var fileId: Int32
public var path: String
public init(fileId: Int32, path: String) {
self.fileId = fileId
self.path = path
}
}
public enum SessionEvent: Equatable, Sendable {
case authChanged(AuthState)
case chatListChanged([ChatSummary])
case folderListChanged([Folder])
case messageAdded(Int64, Message)
case messageUpdated(Int64, Message)
case messageDeleted(Int64, [Int64])
case reactionChanged(Int64, Int64, [Reaction])
case incomingNotificationCandidate(ChatSummary, Message, String)
case networkChanged(NetworkState)
case typingChanged(TypingState)
case draftChanged(Draft)
case profileLoaded(Profile)
case mediaDownloadProgress(fileId: Int32, downloadedSize: Int64, totalSize: Int64)
}
public enum NetworkState: Equatable, Sendable {
case waitingForNetwork
case connectingToProxy
case connecting
case updating
case ready
}
public enum TypingState: Equatable, Sendable {
case idle
case typing(chatId: Int64, userId: Int64, text: String)
}

View File

@@ -0,0 +1,236 @@
import Foundation
import AVFoundation
#if canImport(UserNotifications)
import UserNotifications
#endif
#if canImport(UIKit)
import UIKit
#endif
#if canImport(AppKit)
import AppKit
#endif
public protocol ClipboardWriting: Sendable {
func write(text: String) async
}
public struct SystemClipboardWriter: ClipboardWriting {
public init() {}
public func write(text: String) async {
#if os(iOS) && canImport(UIKit)
await MainActor.run {
UIPasteboard.general.string = text
}
#elseif os(macOS) && canImport(AppKit)
await MainActor.run {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
#endif
}
}
public actor InMemoryClipboardWriter: ClipboardWriting {
public private(set) var lastText: String?
public init() {}
public func write(text: String) async {
lastText = text
}
public func currentText() async -> String? {
lastText
}
}
public struct NotificationPolicy: Sendable {
public init() {}
public func shouldNotify(chat: ChatSummary, message: Message, mentionOnly: Bool) -> Bool {
guard !message.isOutgoing, !chat.isMuted else {
return false
}
if mentionOnly {
return message.text.contains("@")
}
return true
}
}
public protocol NotificationScheduling: Sendable {
func schedule(chat: ChatSummary, message: Message) async throws
}
public struct SystemNotificationScheduler: NotificationScheduling {
public init() {}
public func schedule(chat: ChatSummary, message: Message) async throws {
#if canImport(UserNotifications)
let content = UNMutableNotificationContent()
content.title = chat.title
content.body = "\(message.senderName): \(message.text)"
content.sound = .default
let request = UNNotificationRequest(
identifier: "chat-\(chat.id)-message-\(message.id)",
content: content,
trigger: nil
)
try await UNUserNotificationCenter.current().add(request)
#endif
}
}
public struct NotificationCoordinator: Sendable {
public var policy: NotificationPolicy
public var scheduler: NotificationScheduling
public var mentionOnly: Bool
public init(
policy: NotificationPolicy = NotificationPolicy(),
scheduler: NotificationScheduling,
mentionOnly: Bool = false
) {
self.policy = policy
self.scheduler = scheduler
self.mentionOnly = mentionOnly
}
public func handle(chat: ChatSummary, message: Message) async throws {
guard policy.shouldNotify(chat: chat, message: message, mentionOnly: mentionOnly) else {
return
}
try await scheduler.schedule(chat: chat, message: message)
}
}
public actor RecordingNotificationScheduler: NotificationScheduling {
public private(set) var scheduled: [(ChatSummary, Message)] = []
public init() {}
public func schedule(chat: ChatSummary, message: Message) async throws {
scheduled.append((chat, message))
}
public func scheduledCount() -> Int {
scheduled.count
}
}
public protocol URLOpening: Sendable {
func open(_ url: URL) async
}
public struct SystemURLOpener: URLOpening {
public init() {}
public func open(_ url: URL) async {
#if os(iOS) && canImport(UIKit)
await MainActor.run {
UIApplication.shared.open(url)
}
#elseif os(macOS) && canImport(AppKit)
await MainActor.run {
_ = NSWorkspace.shared.open(url)
}
#endif
}
}
public struct MediaCache: Sendable {
public var root: URL
public init(root: URL) {
self.root = root
}
public func photoPath(fileId: Int32) -> URL {
root.appendingPathComponent("photos", isDirectory: true).appendingPathComponent("\(fileId).jpg")
}
public func voicePath(fileId: Int32) -> URL {
root.appendingPathComponent("voices", isDirectory: true).appendingPathComponent("\(fileId).ogg")
}
}
public protocol VoicePlayback: Sendable {
func load(url: URL) async throws
func play() async
func pause() async
func seek(to seconds: TimeInterval) async
}
public actor SystemVoicePlayer: VoicePlayback {
private var player: AVPlayer?
public init() {}
public func load(url: URL) async throws {
player = AVPlayer(url: url)
}
public func play() async {
player?.play()
}
public func pause() async {
player?.pause()
}
public func seek(to seconds: TimeInterval) async {
await player?.seek(to: CMTime(seconds: seconds, preferredTimescale: 600))
}
}
public actor RecordingVoicePlayer: VoicePlayback {
public private(set) var loadedURL: URL?
public private(set) var isPlaying = false
public private(set) var position: TimeInterval = 0
public init() {}
public func load(url: URL) async throws {
loadedURL = url
position = 0
}
public func currentLoadedURL() async -> URL? {
loadedURL
}
public func play() async {
isPlaying = true
}
public func pause() async {
isPlaying = false
}
public func seek(to seconds: TimeInterval) async {
position = seconds
}
}
@MainActor
public final class AccountSwitcherViewModel: ObservableObject {
@Published public private(set) var accounts: [Account]
@Published public private(set) var activeAccount: Account
public init(accounts: [Account], activeAccount: Account) {
self.accounts = accounts
self.activeAccount = activeAccount
}
public func switchToAccount(id: String) {
guard let next = accounts.first(where: { $0.id == id }) else {
return
}
activeAccount = next
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
public enum SessionBridgeFactory {
public static func makeDefaultBridge(
account: Account,
useFakeTdlib: Bool = true
) -> SessionBridge {
#if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI
do {
return try UniFfiSessionBridge(account: account, useFakeTdlib: useFakeTdlib)
} catch {
return FakeSessionBridge(auth: .waitPhoneNumber)
}
#else
return FakeSessionBridge(auth: .ready)
#endif
}
}

View File

@@ -0,0 +1,45 @@
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 func mediaCachePath(for accountId: String) -> URL {
root
.appendingPathComponent("Accounts", isDirectory: true)
.appendingPathComponent(accountId, isDirectory: true)
.appendingPathComponent("Media", 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
}
}

View File

@@ -0,0 +1,305 @@
import Foundation
#if TELE_IOS_USE_LOCAL_FFI
import tele_ios_ffi
#elseif TELE_IOS_TYPECHECK_UNIFFI
import tele_ios_ffiFFI
#endif
#if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI
public actor UniFfiSessionBridge: SessionBridge {
private let handle: SessionHandle
private let defaultLimit: Int32
public init(account: Account, useFakeTdlib: Bool = true, defaultLimit: Int32 = 100) throws {
self.handle = try createSession(config: IosSessionConfig(
accountId: account.id,
displayName: account.displayName,
databasePath: account.databasePath.path,
useFakeTdlib: useFakeTdlib
))
self.defaultLimit = defaultLimit
}
public init(handle: SessionHandle, defaultLimit: Int32 = 100) {
self.handle = handle
self.defaultLimit = defaultLimit
}
public func authState() async throws -> AuthState {
Self.mapAuthState(handle.authState())
}
public func networkState() async throws -> NetworkState {
Self.mapNetworkState(handle.networkState())
}
public func pollEvents() async throws -> [SessionEvent] {
handle.pollEvents().map(Self.mapEvent)
}
public func sendPhoneNumber(_ phone: String) async throws {
try handle.sendPhoneNumber(phone: phone)
}
public func sendCode(_ code: String) async throws {
try handle.sendCode(code: code)
}
public func sendPassword(_ password: String) async throws {
try handle.sendPassword(password: password)
}
public func loadFolders() async throws -> [Folder] {
handle.loadFolders().map(Self.mapFolder)
}
public func loadChats(folderId: Int32?) async throws -> [ChatSummary] {
let chats = if let folderId {
try handle.loadFolderChats(folderId: folderId, limit: defaultLimit)
} else {
try handle.loadChats(limit: defaultLimit)
}
return chats.map(Self.mapChatSummary)
}
public func loadHistory(chatId: Int64) async throws -> [Message] {
try handle.loadHistory(chatId: chatId, limit: defaultLimit)
.map { Self.mapMessage($0, chatId: chatId) }
}
public func searchMessages(chatId: Int64, query: String) async throws -> [Message] {
try handle.searchMessages(chatId: chatId, query: query)
.map { Self.mapMessage($0.message, chatId: $0.chatId) }
}
public func openProfile(chatId: Int64) async throws -> Profile {
try Self.mapProfile(handle.openProfile(chatId: chatId))
}
public func leaveChat(chatId: Int64) async throws {
try handle.leaveChat(chatId: chatId)
}
public func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message {
try Self.mapMessage(
handle.sendMessage(chatId: chatId, text: text, replyToMessageId: replyToMessageId),
chatId: chatId
)
}
public func editMessage(chatId: Int64, messageId: Int64, text: String) async throws -> Message {
try Self.mapMessage(
handle.editMessage(chatId: chatId, messageId: messageId, text: text),
chatId: chatId
)
}
public func deleteMessages(chatId: Int64, messageIds: [Int64]) async throws {
try handle.deleteMessages(chatId: chatId, messageIds: messageIds, revoke: true)
}
public func forwardMessages(toChatId: Int64, fromChatId: Int64, messageIds: [Int64]) async throws {
try handle.forwardMessages(toChatId: toChatId, fromChatId: fromChatId, messageIds: messageIds)
}
public func react(chatId: Int64, messageId: Int64, reaction: String) async throws -> [Reaction] {
try handle.react(chatId: chatId, messageId: messageId, reaction: reaction)
.map(Self.mapReaction)
}
public func pinnedMessages(chatId: Int64) async throws -> [Message] {
try handle.pinnedMessages(chatId: chatId)
.map { Self.mapMessage($0, chatId: chatId) }
}
public func copyPayload(chatId: Int64, messageId: Int64) async throws -> String {
try handle.copyPayload(chatId: chatId, messageId: messageId)
}
public func setDraft(chatId: Int64, text: String) async throws {
try handle.setDraft(chatId: chatId, text: text)
}
public func downloadPhoto(fileId: Int32) async throws -> DownloadedFile {
try Self.mapDownloadedFile(handle.downloadPhoto(fileId: fileId))
}
public func downloadVoice(fileId: Int32) async throws -> DownloadedFile {
try Self.mapDownloadedFile(handle.downloadVoice(fileId: fileId))
}
private static func mapAuthState(_ state: IosAuthState) -> AuthState {
switch state {
case .waitTdlibParameters:
.waitTdlibParameters
case .waitPhoneNumber:
.waitPhoneNumber
case .waitCode:
.waitCode
case .waitPassword:
.waitPassword
case .ready:
.ready
case .closed:
.closed
case let .error(message):
.error(message)
}
}
private static func mapNetworkState(_ state: IosNetworkState) -> NetworkState {
switch state {
case .waitingForNetwork:
.waitingForNetwork
case .connectingToProxy:
.connectingToProxy
case .connecting:
.connecting
case .updating:
.updating
case .ready:
.ready
}
}
private static func mapTypingState(_ state: IosTypingState) -> TypingState {
switch state {
case .idle:
.idle
case let .typing(chatId, userId, text):
.typing(chatId: chatId, userId: userId, text: text)
}
}
private static func mapFolder(_ folder: IosFolder) -> Folder {
Folder(id: folder.id, name: folder.name)
}
private static func mapDraft(_ draft: IosDraft) -> Draft {
Draft(chatId: draft.chatId, text: draft.text)
}
private static func mapChatSummary(_ chat: IosChatSummary) -> ChatSummary {
ChatSummary(
id: chat.id,
title: chat.title,
username: chat.username,
lastMessage: chat.lastMessage,
unreadCount: chat.unreadCount,
unreadMentionCount: chat.unreadMentionCount,
isPinned: chat.isPinned,
folderIds: chat.folderIds,
isMuted: chat.isMuted,
draft: chat.draft.map(mapDraft)
)
}
private static func mapReaction(_ reaction: IosReaction) -> Reaction {
Reaction(emoji: reaction.emoji, count: reaction.count, isChosen: reaction.isChosen)
}
private static func mapDownloadState(_ state: IosDownloadState) -> MediaDownloadState {
switch state {
case .notDownloaded:
.notDownloaded
case .downloading:
.downloading
case let .downloaded(path):
.downloaded(path: path)
case let .error(message):
.error(message)
}
}
private static func mapMedia(_ media: IosMedia) -> MessageMedia? {
switch media.kind {
case "photo":
.photo(PhotoMedia(
fileId: media.fileId,
width: media.width ?? 0,
height: media.height ?? 0,
downloadState: mapDownloadState(media.downloadState)
))
case "voice":
.voice(VoiceMedia(
fileId: media.fileId,
duration: media.duration ?? 0,
mimeType: media.mimeType,
waveform: media.waveform,
downloadState: mapDownloadState(media.downloadState)
))
default:
nil
}
}
private static func mapMessage(_ message: IosMessage, chatId: Int64) -> Message {
Message(
id: message.id,
chatId: chatId,
senderName: message.senderName,
text: message.text,
date: message.date,
mediaAlbumId: message.mediaAlbumId,
media: message.media.flatMap(mapMedia),
isOutgoing: message.isOutgoing,
isRead: message.isRead,
editDate: message.editDate,
replyText: message.reply?.text,
forwardSenderName: message.forward?.senderName,
reactions: message.reactions.map(mapReaction)
)
}
private static func mapProfile(_ profile: IosProfile) -> Profile {
Profile(
chatId: profile.chatId,
title: profile.title,
username: profile.username,
bio: profile.bio ?? profile.description,
isGroup: profile.isGroup,
memberCount: profile.memberCount
)
}
private static func mapDownloadedFile(_ file: IosDownloadedFile) -> DownloadedFile {
DownloadedFile(fileId: file.fileId, path: file.path)
}
private static func mapEvent(_ event: IosEvent) -> SessionEvent {
switch event {
case let .authChanged(state):
.authChanged(mapAuthState(state))
case let .chatListChanged(chats):
.chatListChanged(chats.map(mapChatSummary))
case let .folderListChanged(folders):
.folderListChanged(folders.map(mapFolder))
case let .messageAdded(chatId, message):
.messageAdded(chatId, mapMessage(message, chatId: chatId))
case let .messageUpdated(chatId, message):
.messageUpdated(chatId, mapMessage(message, chatId: chatId))
case let .messageDeleted(chatId, messageIds):
.messageDeleted(chatId, messageIds)
case let .reactionChanged(chatId, messageId, reactions):
.reactionChanged(chatId, messageId, reactions.map(mapReaction))
case let .incomingNotificationCandidate(chat, message, senderName):
.incomingNotificationCandidate(
mapChatSummary(chat),
mapMessage(message, chatId: chat.id),
senderName
)
case let .networkChanged(state):
.networkChanged(mapNetworkState(state))
case let .typingChanged(state):
.typingChanged(mapTypingState(state))
case let .draftChanged(draft):
.draftChanged(mapDraft(draft))
case let .profileLoaded(profile):
.profileLoaded(mapProfile(profile))
case let .mediaDownloadProgress(fileId, downloadedSize, totalSize):
.mediaDownloadProgress(fileId: fileId, downloadedSize: downloadedSize, totalSize: totalSize)
}
}
}
#endif

View File

@@ -0,0 +1,346 @@
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 typingState: TypingState = .idle
@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 refreshNetworkState() async {
do {
networkState = try await bridge.networkState()
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
public func apply(events: [SessionEvent]) {
for event in events {
switch event {
case let .authChanged(state):
authState = state
case let .networkChanged(state):
networkState = state
case let .typingChanged(state):
typingState = state
default:
break
}
}
}
}
@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 var searchText = ""
@Published public private(set) var searchResults: [Message] = []
@Published public private(set) var pinnedMessages: [Message] = []
@Published public private(set) var copiedPayload: String?
@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)
pinnedMessages = try await bridge.pinnedMessages(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
}
}
public func search() async {
do {
searchResults = try await bridge.searchMessages(chatId: chat.id, query: searchText)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
public func beginReply(to message: Message) {
replyTo = message
}
public func cancelReply() {
replyTo = nil
}
public func edit(message: Message, text: String) async {
do {
let edited = try await bridge.editMessage(chatId: chat.id, messageId: message.id, text: text)
replaceMessage(edited)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
public func delete(message: Message) async {
do {
try await bridge.deleteMessages(chatId: chat.id, messageIds: [message.id])
messages.removeAll { $0.id == message.id }
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
public func forward(message: Message, to chatId: Int64) async {
do {
try await bridge.forwardMessages(toChatId: chatId, fromChatId: chat.id, messageIds: [message.id])
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
public func react(message: Message, reaction: String) async {
do {
let reactions = try await bridge.react(chatId: chat.id, messageId: message.id, reaction: reaction)
var updated = message
updated.reactions = reactions
replaceMessage(updated)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
public func copyPayload(for message: Message) async {
do {
copiedPayload = try await bridge.copyPayload(chatId: chat.id, messageId: message.id)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
public func saveDraft() async {
do {
try await bridge.setDraft(chatId: chat.id, text: composeText)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
private func replaceMessage(_ message: Message) {
if let index = messages.firstIndex(where: { $0.id == message.id }) {
messages[index] = message
}
}
}
@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
}
}
public func leave(chatId: Int64) async {
do {
try await bridge.leaveChat(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?
@Published public private(set) var isVoicePlaying = false
private let cache: MediaCache?
private let voicePlayer: VoicePlayback?
public init(cache: MediaCache? = nil, voicePlayer: VoicePlayback? = nil) {
self.cache = cache
self.voicePlayer = voicePlayer
}
public func showPhoto(path: String) {
activePhotoPath = path
}
public func showVoice(path: String) {
activeVoicePath = path
}
public func cachedPhotoPath(fileId: Int32) -> URL? {
cache?.photoPath(fileId: fileId)
}
public func cachedVoicePath(fileId: Int32) -> URL? {
cache?.voicePath(fileId: fileId)
}
public func playVoice(url: URL) async {
do {
try await voicePlayer?.load(url: url)
await voicePlayer?.play()
isVoicePlaying = true
} catch {
isVoicePlaying = false
}
}
public func pauseVoice() async {
await voicePlayer?.pause()
isVoicePlaying = false
}
}

View File

@@ -0,0 +1,964 @@
import SwiftUI
public struct RootView: View {
@StateObject private var store: SessionStore
@StateObject private var authViewModel: AuthViewModel
@StateObject private var chatListViewModel: ChatListViewModel
public init(store: SessionStore) {
let authViewModel = AuthViewModel(store: store)
let chatListViewModel = ChatListViewModel(bridge: store.bridge)
_store = StateObject(wrappedValue: store)
_authViewModel = StateObject(wrappedValue: authViewModel)
_chatListViewModel = StateObject(wrappedValue: chatListViewModel)
}
public var body: some View {
Group {
switch store.authState {
case .ready:
ChatListView(
viewModel: chatListViewModel,
bridge: store.bridge,
networkState: store.networkState,
typingState: store.typingState
)
default:
AuthView(state: store.authState, viewModel: authViewModel)
}
}
.task {
await store.refreshAuthState()
await store.refreshNetworkState()
}
}
}
public struct AuthView: View {
public var state: AuthState
@ObservedObject public var viewModel: AuthViewModel
public init(state: AuthState, viewModel: AuthViewModel) {
self.state = state
self.viewModel = viewModel
}
public var body: some View {
VStack(spacing: 16) {
Text("Telegram")
.font(.largeTitle)
.fontWeight(.semibold)
authField
Button(action: {
Task { await viewModel.submitCurrentStep() }
}) {
Text(buttonTitle)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading || !canSubmit)
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
}
.padding()
}
@ViewBuilder
private var authField: some View {
switch state {
case .waitPhoneNumber, .waitTdlibParameters:
TextField("Phone number", text: $viewModel.phone)
.textContentType(.telephoneNumber)
.textFieldStyle(.roundedBorder)
case .waitCode:
TextField("Code", text: $viewModel.code)
.textContentType(.oneTimeCode)
.textFieldStyle(.roundedBorder)
case .waitPassword:
SecureField("Password", text: $viewModel.password)
.textContentType(.password)
.textFieldStyle(.roundedBorder)
case .ready:
Text("Ready")
case .closed:
Text("Session closed")
case let .error(message):
Text(message)
.foregroundStyle(.red)
}
}
private var buttonTitle: String {
switch state {
case .waitPhoneNumber, .waitTdlibParameters:
"Continue"
case .waitCode:
"Verify"
case .waitPassword:
"Unlock"
default:
"Continue"
}
}
private var canSubmit: Bool {
switch state {
case .waitPhoneNumber, .waitTdlibParameters:
!viewModel.phone.isEmpty
case .waitCode:
!viewModel.code.isEmpty
case .waitPassword:
!viewModel.password.isEmpty
default:
false
}
}
}
public struct ChatListView: View {
@ObservedObject public var viewModel: ChatListViewModel
public let bridge: SessionBridge
public var networkState: NetworkState
public var typingState: TypingState
@State private var selectedChat: ChatSummary?
@State private var showsAccountSwitcher = false
public init(
viewModel: ChatListViewModel,
bridge: SessionBridge,
networkState: NetworkState = .ready,
typingState: TypingState = .idle
) {
self.viewModel = viewModel
self.bridge = bridge
self.networkState = networkState
self.typingState = typingState
}
public var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
List(selection: $selectedChat) {
ForEach(viewModel.filteredChats) { chat in
NavigationLink(value: chat) {
ChatRow(chat: chat)
}
}
}
ChatListStatusBar(networkState: networkState, typingState: typingState)
}
.navigationTitle("Chats")
.searchable(text: $viewModel.searchText)
.toolbar {
ToolbarItem {
Button("Accounts") {
showsAccountSwitcher = true
}
}
ToolbarItem {
folderMenu
}
}
.sheet(isPresented: $showsAccountSwitcher) {
AccountSwitcherView()
}
.task {
await viewModel.load()
}
} detail: {
if let selectedChat {
ChatDetailView(viewModel: ChatViewModel(chat: selectedChat, bridge: bridge), bridge: bridge) {
self.selectedChat = nil
Task { await viewModel.load() }
}
} else {
Text("Select a chat")
}
}
}
private var folderMenu: some View {
Menu("Folders") {
Button("All") {
viewModel.selectedFolderId = nil
Task { await viewModel.load() }
}
ForEach(viewModel.folders) { folder in
Button(folder.name) {
viewModel.selectedFolderId = folder.id
Task { await viewModel.load() }
}
}
}
}
}
public struct ChatListStatusBar: View {
public var networkState: NetworkState
public var typingState: TypingState
public init(networkState: NetworkState, typingState: TypingState) {
self.networkState = networkState
self.typingState = typingState
}
public var body: some View {
HStack(spacing: 8) {
Image(systemName: networkIconName)
.foregroundStyle(networkState == .ready ? .green : .orange)
Text(statusText)
.font(.footnote)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
}
private var networkIconName: String {
switch networkState {
case .ready:
"checkmark.circle.fill"
case .waitingForNetwork:
"wifi.slash"
case .connectingToProxy:
"shield.lefthalf.filled"
case .connecting:
"antenna.radiowaves.left.and.right"
case .updating:
"arrow.triangle.2.circlepath"
}
}
private var statusText: String {
switch typingState {
case let .typing(_, _, text) where !text.isEmpty:
text
case .typing:
"Typing"
case .idle:
networkText
}
}
private var networkText: String {
switch networkState {
case .ready:
"Online"
case .waitingForNetwork:
"Waiting for network"
case .connectingToProxy:
"Connecting to proxy"
case .connecting:
"Connecting"
case .updating:
"Updating"
}
}
}
public struct ChatRow: View {
public var chat: ChatSummary
public init(chat: ChatSummary) {
self.chat = chat
}
public var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(chat.title)
.font(.headline)
.lineLimit(1)
if chat.isPinned {
Image(systemName: "pin.fill")
.font(.caption)
}
if chat.isMuted {
Image(systemName: "bell.slash")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if chat.unreadMentionCount > 0 {
Text("@\(chat.unreadMentionCount)")
.font(.caption)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.orange, in: Capsule())
.foregroundStyle(.white)
}
if chat.unreadCount > 0 {
Text("\(chat.unreadCount)")
.font(.caption)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.blue, in: Capsule())
.foregroundStyle(.white)
}
}
HStack(spacing: 4) {
if chat.draft != nil {
Text("Draft")
.fontWeight(.semibold)
}
Text(chat.draft?.text ?? chat.lastMessage)
}
.font(.subheadline)
.foregroundStyle(chat.draft == nil ? Color.secondary : Color.red)
.lineLimit(2)
}
.padding(.vertical, 4)
}
}
public struct ChatDetailView: View {
@StateObject public var viewModel: ChatViewModel
public let bridge: SessionBridge
public let clipboard: ClipboardWriting
@StateObject private var profileViewModel: ProfileViewModel
@State private var showsProfile = false
@State private var editingMessage: Message?
@State private var editedText = ""
@State private var deleteCandidate: Message?
@State private var forwardCandidate: Message?
@State private var reactionCandidate: Message?
@State private var forwardChatIdText = ""
private let onChatLeft: () -> Void
public init(
viewModel: ChatViewModel,
bridge: SessionBridge,
clipboard: ClipboardWriting = SystemClipboardWriter(),
onChatLeft: @escaping () -> Void = {}
) {
_viewModel = StateObject(wrappedValue: viewModel)
self.bridge = bridge
self.clipboard = clipboard
self.onChatLeft = onChatLeft
_profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge))
}
public var body: some View {
VStack(spacing: 0) {
ScrollViewReader { scrollProxy in
VStack(spacing: 0) {
if !viewModel.pinnedMessages.isEmpty {
PinnedMessagesBar(messages: viewModel.pinnedMessages) { message in
withAnimation {
scrollProxy.scrollTo(message.id, anchor: .center)
}
}
}
List {
if !viewModel.searchText.isEmpty {
Section("Search") {
if viewModel.searchResults.isEmpty {
Text("No results")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.searchResults) { message in
MessageRow(message: message)
.listRowSeparator(.hidden)
}
}
}
}
Section {
ForEach(Array(viewModel.messages.enumerated()), id: \.element.id) { index, message in
if shouldShowDateSeparator(at: index) {
DateSeparatorView(timestamp: message.date)
.listRowSeparator(.hidden)
}
MessageRow(message: message, showsSender: shouldShowSender(at: index))
.id(message.id)
.contextMenu {
Button {
viewModel.beginReply(to: message)
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
Button {
editingMessage = message
editedText = message.text
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
forwardCandidate = message
forwardChatIdText = ""
} label: {
Label("Forward", systemImage: "arrowshape.turn.up.forward")
}
Button {
reactionCandidate = message
} label: {
Label("React", systemImage: "face.smiling")
}
Button {
Task {
await viewModel.copyPayload(for: message)
if let payload = viewModel.copiedPayload {
await clipboard.write(text: payload)
}
}
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button(role: .destructive) {
deleteCandidate = message
} label: {
Label("Delete", systemImage: "trash")
}
}
.listRowSeparator(.hidden)
}
}
}
}
}
ComposeBar(
text: $viewModel.composeText,
replyTo: viewModel.replyTo,
cancelReply: { viewModel.cancelReply() }
) {
Task { await viewModel.send() }
}
}
.navigationTitle(viewModel.chat.title)
.searchable(text: $viewModel.searchText)
.onSubmit(of: .search) {
Task { await viewModel.search() }
}
.toolbar {
Button("Profile") {
showsProfile = true
Task { await profileViewModel.load(chatId: viewModel.chat.id) }
}
}
.sheet(isPresented: $showsProfile) {
ProfileView(viewModel: profileViewModel, chatId: viewModel.chat.id) {
showsProfile = false
onChatLeft()
}
}
.alert("Edit Message", isPresented: editAlertBinding) {
TextField("Message", text: $editedText)
Button("Save") {
if let editingMessage {
Task { await viewModel.edit(message: editingMessage, text: editedText) }
}
editingMessage = nil
}
Button("Cancel", role: .cancel) {
editingMessage = nil
}
}
.alert("Delete Message", isPresented: deleteAlertBinding) {
Button("Delete", role: .destructive) {
if let deleteCandidate {
Task { await viewModel.delete(message: deleteCandidate) }
}
deleteCandidate = nil
}
Button("Cancel", role: .cancel) {
deleteCandidate = nil
}
}
.alert("Forward Message", isPresented: forwardAlertBinding) {
TextField("Chat ID", text: $forwardChatIdText)
Button("Forward") {
if let forwardCandidate, let chatId = Int64(forwardChatIdText) {
Task { await viewModel.forward(message: forwardCandidate, to: chatId) }
}
forwardCandidate = nil
}
Button("Cancel", role: .cancel) {
forwardCandidate = nil
}
}
.confirmationDialog("React", isPresented: reactionDialogBinding, titleVisibility: .visible) {
ForEach(["👍", "❤️", "😂", "😮", "😢", "🙏"], id: \.self) { reaction in
Button(reaction) {
if let reactionCandidate {
Task { await viewModel.react(message: reactionCandidate, reaction: reaction) }
}
reactionCandidate = nil
}
}
Button("Cancel", role: .cancel) {
reactionCandidate = nil
}
}
.task {
await viewModel.load()
}
}
private var editAlertBinding: Binding<Bool> {
Binding(
get: { editingMessage != nil },
set: { isPresented in
if !isPresented {
editingMessage = nil
}
}
)
}
private var deleteAlertBinding: Binding<Bool> {
Binding(
get: { deleteCandidate != nil },
set: { isPresented in
if !isPresented {
deleteCandidate = nil
}
}
)
}
private var forwardAlertBinding: Binding<Bool> {
Binding(
get: { forwardCandidate != nil },
set: { isPresented in
if !isPresented {
forwardCandidate = nil
}
}
)
}
private var reactionDialogBinding: Binding<Bool> {
Binding(
get: { reactionCandidate != nil },
set: { isPresented in
if !isPresented {
reactionCandidate = nil
}
}
)
}
private func shouldShowDateSeparator(at index: Int) -> Bool {
guard viewModel.messages.indices.contains(index), viewModel.messages[index].date > 0 else {
return false
}
guard index > 0, viewModel.messages.indices.contains(index - 1) else {
return true
}
let current = Date(timeIntervalSince1970: TimeInterval(viewModel.messages[index].date))
let previous = Date(timeIntervalSince1970: TimeInterval(viewModel.messages[index - 1].date))
return !Calendar.current.isDate(current, inSameDayAs: previous)
}
private func shouldShowSender(at index: Int) -> Bool {
guard viewModel.messages.indices.contains(index) else {
return true
}
let message = viewModel.messages[index]
guard !message.isOutgoing else {
return false
}
guard index > 0, viewModel.messages.indices.contains(index - 1) else {
return true
}
let previous = viewModel.messages[index - 1]
return previous.isOutgoing
|| previous.senderName != message.senderName
|| shouldShowDateSeparator(at: index)
}
}
public struct DateSeparatorView: View {
public var timestamp: Int32
public init(timestamp: Int32) {
self.timestamp = timestamp
}
public var body: some View {
HStack {
Spacer()
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.12), in: Capsule())
Spacer()
}
.padding(.vertical, 6)
}
private var label: String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
let calendar = Calendar.current
if calendar.isDateInToday(date) {
return "Today"
}
if calendar.isDateInYesterday(date) {
return "Yesterday"
}
return Self.formatter.string(from: date)
}
private static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter
}()
}
public struct PinnedMessagesBar: View {
public var messages: [Message]
public var select: (Message) -> Void
public init(messages: [Message], select: @escaping (Message) -> Void) {
self.messages = messages
self.select = select
}
public var body: some View {
VStack(spacing: 0) {
ForEach(messages.prefix(3)) { message in
Button {
select(message)
} label: {
HStack(spacing: 8) {
Image(systemName: "pin.fill")
.font(.caption)
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(message.senderName)
.font(.caption2)
.foregroundStyle(.secondary)
Text(message.text)
.font(.subheadline)
.lineLimit(1)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.background(Color.blue.opacity(0.08))
.overlay(alignment: .bottom) {
Divider()
}
}
}
public struct MessageRow: View {
public var message: Message
public var showsSender: Bool
public init(message: Message, showsSender: Bool = true) {
self.message = message
self.showsSender = showsSender
}
public var body: some View {
HStack {
if message.isOutgoing {
Spacer(minLength: 48)
}
VStack(alignment: .leading, spacing: 5) {
if showsSender && !message.isOutgoing {
Text(message.senderName)
.font(.caption)
.foregroundStyle(.secondary)
}
if let replyText = message.replyText {
Text(replyText)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 6)
}
if let forwardSenderName = message.forwardSenderName {
Label(forwardSenderName, systemImage: "arrowshape.turn.up.forward")
.font(.caption)
.foregroundStyle(.secondary)
}
if let media = message.media {
MediaPlaceholderView(media: media, mediaAlbumId: message.mediaAlbumId)
}
Text(renderedText)
.textSelection(.enabled)
if !message.reactions.isEmpty {
Text(message.reactions.map(\.emoji).joined(separator: " "))
.font(.caption)
}
if message.editDate != nil || message.date > 0 || message.isOutgoing {
HStack(spacing: 6) {
if message.date > 0 {
Text(Self.timeFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(message.date))))
}
if message.editDate != nil {
Text("edited")
}
if message.isOutgoing {
Image(systemName: message.isRead ? "checkmark.circle.fill" : "checkmark.circle")
}
}
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(message.isOutgoing ? Color.blue.opacity(0.16) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
if !message.isOutgoing {
Spacer(minLength: 48)
}
}
}
private var renderedText: AttributedString {
(
try? AttributedString(
markdown: message.text,
options: AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
)
) ?? AttributedString(message.text)
}
private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
}
public struct MediaPlaceholderView: View {
public var media: MessageMedia
public var mediaAlbumId: Int64?
public init(media: MessageMedia, mediaAlbumId: Int64? = nil) {
self.media = media
self.mediaAlbumId = mediaAlbumId
}
public var body: some View {
HStack(spacing: 8) {
Image(systemName: iconName)
.frame(width: 22, height: 22)
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(title)
.font(.subheadline)
if mediaAlbumId != nil {
Image(systemName: "square.stack.3d.up.fill")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(8)
.background(Color.gray.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
}
private var iconName: String {
switch media {
case .photo:
"photo"
case .voice:
"waveform"
}
}
private var title: String {
switch media {
case .photo:
"Photo"
case .voice:
"Voice"
}
}
private var detail: String {
switch media {
case let .photo(photo):
"\(photo.width)x\(photo.height) · \(downloadLabel(photo.downloadState))"
case let .voice(voice):
"\(voice.duration)s · \(downloadLabel(voice.downloadState))"
}
}
private func downloadLabel(_ state: MediaDownloadState) -> String {
switch state {
case .notDownloaded:
"not downloaded"
case .downloading:
"downloading"
case .downloaded:
"downloaded"
case .error:
"error"
}
}
}
public struct ComposeBar: View {
@Binding public var text: String
public var replyTo: Message?
public var cancelReply: () -> Void
public var send: () -> Void
public init(
text: Binding<String>,
replyTo: Message? = nil,
cancelReply: @escaping () -> Void = {},
send: @escaping () -> Void
) {
_text = text
self.replyTo = replyTo
self.cancelReply = cancelReply
self.send = send
}
public var body: some View {
VStack(spacing: 8) {
if let replyTo {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(replyTo.senderName)
.font(.caption)
.foregroundStyle(.secondary)
Text(replyTo.text)
.font(.footnote)
.lineLimit(1)
}
Spacer()
Button(action: cancelReply) {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
}
HStack(spacing: 10) {
if !text.isEmpty {
Button {
text = ""
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
}
TextField("Message", text: $text, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
Button {
send()
} label: {
Image(systemName: "paperplane.fill")
}
.buttonStyle(.borderedProminent)
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding()
.background(.bar)
}
}
public struct ProfileView: View {
@ObservedObject public var viewModel: ProfileViewModel
public var chatId: Int64?
public var onLeave: () -> Void
@State private var confirmsLeave = false
public init(viewModel: ProfileViewModel, chatId: Int64? = nil, onLeave: @escaping () -> Void = {}) {
self.viewModel = viewModel
self.chatId = chatId
self.onLeave = onLeave
}
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")
}
}
if profile.isGroup, chatId != nil {
Section {
Button(role: .destructive) {
confirmsLeave = true
} label: {
Label("Leave Chat", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
} else {
ProgressView()
}
}
.navigationTitle("Profile")
.alert("Leave Chat", isPresented: $confirmsLeave) {
Button("Leave", role: .destructive) {
if let chatId {
Task {
await viewModel.leave(chatId: chatId)
if viewModel.errorMessage == nil {
onLeave()
}
}
}
}
Button("Cancel", role: .cancel) {}
}
}
}
}
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")
}
}
}

View File

@@ -0,0 +1,252 @@
import Foundation
import TeleTuiIOSCore
@main
struct TeleTuiIOSSmokeTests {
static func main() async throws {
try await authFlowMatchesAllInteractiveStates()
try await chatListLoadsDeterministicFakeDataAndFilters()
try await chatDetailLoadsAndSendsMessage()
try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy()
try await sessionBridgeFactoryUsesAvailableDefaultBridge()
try await platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts()
lifecycleCoordinatorDropsStaleAccountEvents()
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)
await store.refreshNetworkState()
precondition(store.networkState == .ready)
store.apply(events: [.typingChanged(.typing(chatId: 1, userId: 10, text: "typing"))])
precondition(store.typingState == .typing(chatId: 1, userId: 10, text: "typing"))
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.folders.map(\.name) == ["All", "Work"])
precondition(viewModel.chats.map(\.title) == ["Saved Messages", "iOS Team"])
viewModel.searchText = "team"
precondition(viewModel.filteredChats.map(\.title) == ["iOS Team"])
viewModel.searchText = ""
viewModel.selectedFolderId = 2
await viewModel.load()
precondition(viewModel.chats.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)
precondition(viewModel.messages[0].date == 1_700_000_000)
precondition(viewModel.pinnedMessages.map(\.id) == [1])
if case let .photo(photo) = viewModel.messages[0].media {
precondition(photo.fileId == 100)
precondition(photo.width == 1280)
precondition(photo.height == 720)
} else {
preconditionFailure("fake saved message should contain photo media")
}
viewModel.composeText = "Hi from SwiftUI"
await viewModel.send()
precondition(viewModel.messages.last?.text == "Hi from SwiftUI")
precondition(viewModel.composeText.isEmpty)
}
@MainActor
private static func messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy() 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()
guard let first = viewModel.messages.first else {
preconditionFailure("fake chat should contain a message")
}
await viewModel.edit(message: first, text: "Edited text")
precondition(viewModel.messages.first?.text == "Edited text")
precondition(viewModel.messages.first?.editDate != nil)
viewModel.beginReply(to: viewModel.messages[0])
viewModel.composeText = "Reply text"
await viewModel.send()
precondition(viewModel.messages.last?.replyText == "Reply to #1")
await viewModel.react(message: viewModel.messages[0], reaction: "👍")
precondition(viewModel.messages[0].reactions.first?.emoji == "👍")
viewModel.searchText = "reply"
await viewModel.search()
precondition(viewModel.searchResults.count == 1)
await viewModel.copyPayload(for: viewModel.messages[0])
precondition(viewModel.copiedPayload == "Edited text")
viewModel.composeText = "Draft text"
await viewModel.saveDraft()
let draftEvents = try await bridge.pollEvents()
precondition(draftEvents.contains { event in
if case let .draftChanged(draft) = event {
return draft.chatId == chat.id && draft.text == "Draft text"
}
return false
})
let photo = try await bridge.downloadPhoto(fileId: 100)
let voice = try await bridge.downloadVoice(fileId: 200)
precondition(photo.path == "/tmp/fake-photo-100.jpg")
precondition(voice.path == "/tmp/fake-voice-200.ogg")
await viewModel.forward(message: viewModel.messages[0], to: 2)
let forwarded = try await bridge.loadHistory(chatId: 2)
precondition(forwarded.contains { $0.forwardSenderName == "Alice" && $0.text == "Edited text" })
await viewModel.delete(message: viewModel.messages[0])
precondition(!viewModel.messages.contains { $0.id == 1 })
}
@MainActor
private static func sessionBridgeFactoryUsesAvailableDefaultBridge() async throws {
let account = Account(id: "factory", displayName: "Factory", databasePath: URL(fileURLWithPath: "/tmp/factory"))
let bridge = SessionBridgeFactory.makeDefaultBridge(account: account)
let auth = try await bridge.authState()
precondition(auth == .ready)
}
@MainActor
private static func platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts() async throws {
let root = URL(fileURLWithPath: "/tmp/TeleTuiIOS")
let paths = AppStoragePaths(root: root)
let cache = MediaCache(root: paths.mediaCachePath(for: "work"))
precondition(cache.photoPath(fileId: 10).path == "/tmp/TeleTuiIOS/Accounts/work/Media/photos/10.jpg")
precondition(cache.voicePath(fileId: 20).path == "/tmp/TeleTuiIOS/Accounts/work/Media/voices/20.ogg")
let policy = NotificationPolicy()
let chat = ChatSummary(id: 1, title: "Chat", lastMessage: "hello", isMuted: false)
let muted = ChatSummary(id: 2, title: "Muted", lastMessage: "hello", isMuted: true)
let incomingMention = Message(id: 1, chatId: 1, senderName: "Alice", text: "@me hello", isOutgoing: false)
let incomingPlain = Message(id: 2, chatId: 1, senderName: "Alice", text: "hello", isOutgoing: false)
precondition(policy.shouldNotify(chat: chat, message: incomingMention, mentionOnly: true))
precondition(!policy.shouldNotify(chat: chat, message: incomingPlain, mentionOnly: true))
precondition(!policy.shouldNotify(chat: muted, message: incomingMention, mentionOnly: false))
let scheduler = RecordingNotificationScheduler()
let mentionCoordinator = NotificationCoordinator(scheduler: scheduler, mentionOnly: true)
try await mentionCoordinator.handle(chat: chat, message: incomingPlain)
var scheduledCount = await scheduler.scheduledCount()
precondition(scheduledCount == 0)
try await mentionCoordinator.handle(chat: chat, message: incomingMention)
scheduledCount = await scheduler.scheduledCount()
precondition(scheduledCount == 1)
try await mentionCoordinator.handle(chat: muted, message: incomingMention)
scheduledCount = await scheduler.scheduledCount()
precondition(scheduledCount == 1)
let clipboard = InMemoryClipboardWriter()
await clipboard.write(text: "copied")
let copiedText = await clipboard.currentText()
precondition(copiedText == "copied")
let player = RecordingVoicePlayer()
let mediaViewModel = MediaViewModel(cache: cache, voicePlayer: player)
mediaViewModel.showPhoto(path: "/tmp/photo.jpg")
mediaViewModel.showVoice(path: "/tmp/voice.ogg")
precondition(mediaViewModel.activePhotoPath == "/tmp/photo.jpg")
precondition(mediaViewModel.activeVoicePath == "/tmp/voice.ogg")
let voiceURL = cache.voicePath(fileId: 20)
await mediaViewModel.playVoice(url: voiceURL)
precondition(mediaViewModel.isVoicePlaying)
let loadedURL = await player.currentLoadedURL()
precondition(loadedURL == voiceURL)
await mediaViewModel.pauseVoice()
precondition(!mediaViewModel.isVoicePlaying)
let personal = Account(id: "personal", displayName: "Personal", databasePath: paths.databasePath(for: "personal"))
let work = Account(id: "work", displayName: "Work", databasePath: paths.databasePath(for: "work"))
let switcher = AccountSwitcherViewModel(accounts: [personal, work], activeAccount: personal)
switcher.switchToAccount(id: "work")
precondition(switcher.activeAccount.id == "work")
precondition(switcher.activeAccount.databasePath.path.hasSuffix("/Accounts/work/tdlib"))
}
@MainActor
private static func lifecycleCoordinatorDropsStaleAccountEvents() {
let coordinator = SessionLifecycleCoordinator(activeAccountId: "personal")
precondition(coordinator.shouldPollEvents)
coordinator.enterBackground()
precondition(!coordinator.shouldPollEvents)
coordinator.enterForeground()
precondition(coordinator.shouldPollEvents)
let oldGenerationEvent = ScopedSessionEvent(
accountId: "personal",
generation: coordinator.generation,
event: .authChanged(.ready)
)
precondition(coordinator.accepts(oldGenerationEvent))
coordinator.switchAccount(to: "work")
precondition(!coordinator.accepts(oldGenerationEvent))
let newGenerationEvent = ScopedSessionEvent(
accountId: "work",
generation: coordinator.generation,
event: .authChanged(.ready)
)
precondition(coordinator.accepts(newGenerationEvent))
}
@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")
await viewModel.leave(chatId: 1)
let chats = try await bridge.loadChats(folderId: nil)
precondition(!chats.contains { $0.id == 1 })
}
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")
}
}

View File

@@ -0,0 +1,31 @@
[package]
name = "tele-core"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
description = "Reusable Telegram/TDLib core for tele-tui"
license = "MIT"
repository = "https://github.com/your-username/tele-tui"
keywords = ["telegram", "tdlib"]
categories = ["api-bindings"]
[features]
default = ["tdlib-download"]
images = []
test-support = []
tdlib-download = ["tdlib-rs/download-tdlib"]
tdlib-local = ["tdlib-rs/local-tdlib"]
[dependencies]
tdlib-rs = { version = "1.2.0", default-features = false }
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = "0.4"
thiserror = "1.0"
tracing = "0.1"
base64 = "0.22.1"
[dev-dependencies]
tokio-test = "0.4"

View File

@@ -0,0 +1,5 @@
//! Account profile data structures and validation.
pub mod profile;
pub use profile::{validate_account_name, AccountProfile, AccountsConfig};

View File

@@ -0,0 +1,114 @@
//! Account profile data structures and validation.
//!
//! Defines `AccountProfile` and `AccountsConfig` for multi-account support.
//! Account names are validated to contain only alphanumeric characters, hyphens, and underscores.
use serde::{Deserialize, Serialize};
/// Configuration for all accounts, stored in `~/.config/tele-tui/accounts.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountsConfig {
/// Name of the default account to use when no `--account` flag is provided.
pub default_account: String,
/// List of configured accounts.
pub accounts: Vec<AccountProfile>,
}
/// A single account profile.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountProfile {
/// Unique identifier (used in directory names and CLI flag).
pub name: String,
/// Human-readable display name.
pub display_name: String,
}
impl AccountsConfig {
/// Creates a default config with a single "default" account.
pub fn default_single() -> Self {
Self {
default_account: "default".to_string(),
accounts: vec![AccountProfile {
name: "default".to_string(),
display_name: "Default".to_string(),
}],
}
}
/// Finds an account by name.
pub fn find_account(&self, name: &str) -> Option<&AccountProfile> {
self.accounts.iter().find(|a| a.name == name)
}
}
/// Validates an account name.
///
/// Valid names contain only lowercase alphanumeric characters, hyphens, and underscores.
/// Must be 1-32 characters long.
///
/// # Errors
///
/// Returns a descriptive error message if the name is invalid.
pub fn validate_account_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Account name cannot be empty".to_string());
}
if name.len() > 32 {
return Err("Account name cannot be longer than 32 characters".to_string());
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
return Err(
"Account name can only contain lowercase letters, digits, hyphens, and underscores"
.to_string(),
);
}
if name.starts_with('-') || name.starts_with('_') {
return Err("Account name cannot start with a hyphen or underscore".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_account_name_valid() {
assert!(validate_account_name("default").is_ok());
assert!(validate_account_name("work").is_ok());
assert!(validate_account_name("my-account").is_ok());
assert!(validate_account_name("account_2").is_ok());
assert!(validate_account_name("a").is_ok());
}
#[test]
fn test_validate_account_name_invalid() {
assert!(validate_account_name("").is_err());
assert!(validate_account_name("My Account").is_err());
assert!(validate_account_name("UPPER").is_err());
assert!(validate_account_name("with spaces").is_err());
assert!(validate_account_name("-starts-with-dash").is_err());
assert!(validate_account_name("_starts-with-underscore").is_err());
assert!(validate_account_name(&"a".repeat(33)).is_err());
}
#[test]
fn test_default_single_config() {
let config = AccountsConfig::default_single();
assert_eq!(config.default_account, "default");
assert_eq!(config.accounts.len(), 1);
assert_eq!(config.accounts[0].name, "default");
}
#[test]
fn test_find_account() {
let config = AccountsConfig::default_single();
assert!(config.find_account("default").is_some());
assert!(config.find_account("nonexistent").is_none());
}
}

View File

@@ -0,0 +1,6 @@
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
pub const MAX_USER_CACHE_SIZE: usize = 500;
pub const MAX_CHATS: usize = 200;
pub const MAX_CHAT_USER_IDS: usize = 500;
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;

View File

@@ -0,0 +1,12 @@
//! Reusable Telegram/TDLib core for tele-tui and future clients.
mod constants;
mod utils;
pub mod accounts;
pub mod message_grouping;
pub mod session;
pub mod tdlib;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
pub mod types;

View File

@@ -35,10 +35,10 @@ pub enum MessageGroup<'a> {
/// # Примеры
///
/// ```no_run
/// use tele_tui::message_grouping::{group_messages, MessageGroup};
/// use tele_core::message_grouping::{group_messages, MessageGroup};
///
/// # use tele_tui::tdlib::types::MessageBuilder;
/// # use tele_tui::types::MessageId;
/// # use tele_core::tdlib::types::MessageBuilder;
/// # use tele_core::types::MessageId;
/// # let msg = MessageBuilder::new(MessageId::new(1)).sender_name("Alice").text("Hello").build();
/// let messages = vec![msg];
/// let grouped = group_messages(&messages);

View File

@@ -0,0 +1,997 @@
use crate::tdlib::types::ForwardInfo;
use crate::tdlib::{
AuthState, ChatInfo, FolderInfo, MediaInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo,
TdClientTrait,
};
use crate::types::{ChatId, MessageId, UserId};
use std::collections::VecDeque;
/// Platform-neutral Telegram session facade for native clients.
#[derive(Debug, Clone)]
pub struct CoreSession<C> {
client: C,
events: VecDeque<CoreEvent>,
}
impl<C> CoreSession<C> {
pub fn new(client: C) -> Self {
Self { client, events: VecDeque::new() }
}
pub fn client(&self) -> &C {
&self.client
}
pub fn client_mut(&mut self) -> &mut C {
&mut self.client
}
pub fn into_client(self) -> C {
self.client
}
pub fn enqueue_event(&mut self, event: CoreEvent) {
self.events.push_back(event);
}
pub fn poll_events(&mut self) -> Vec<CoreEvent> {
self.events.drain(..).collect()
}
}
impl<C: TdClientTrait> CoreSession<C> {
pub fn auth_state(&self) -> CoreAuthState {
CoreAuthState::from(self.client.auth_state())
}
pub fn network_state(&self) -> CoreNetworkState {
CoreNetworkState::from(&self.client.network_state())
}
pub fn emit_auth_state(&mut self) -> CoreAuthState {
let state = self.auth_state();
self.enqueue_event(CoreEvent::AuthChanged(state.clone()));
state
}
pub fn emit_network_state(&mut self) -> CoreNetworkState {
let state = self.network_state();
self.enqueue_event(CoreEvent::NetworkChanged(state.clone()));
state
}
pub async fn send_phone_number(&self, phone: String) -> Result<(), String> {
self.client.send_phone_number(phone).await
}
pub async fn send_code(&self, code: String) -> Result<(), String> {
self.client.send_code(code).await
}
pub async fn send_password(&self, password: String) -> Result<(), String> {
self.client.send_password(password).await
}
pub async fn load_chats(&mut self, limit: i32) -> Result<Vec<CoreChatSummary>, String> {
self.client.load_chats(limit).await?;
let chats = self.chat_summaries();
self.enqueue_event(CoreEvent::ChatListChanged(chats.clone()));
Ok(chats)
}
pub async fn load_folder_chats(
&mut self,
folder_id: i32,
limit: i32,
) -> Result<Vec<CoreChatSummary>, String> {
self.client.load_folder_chats(folder_id, limit).await?;
let chats = self.chat_summaries();
self.enqueue_event(CoreEvent::ChatListChanged(chats.clone()));
Ok(chats)
}
pub fn chat_summaries(&self) -> Vec<CoreChatSummary> {
self.client
.chats()
.iter()
.map(CoreChatSummary::from)
.collect()
}
pub fn folders(&self) -> Vec<CoreFolder> {
self.client.folders().iter().map(CoreFolder::from).collect()
}
pub async fn open_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<CoreMessage>, String> {
self.client.set_current_chat_id(Some(chat_id));
let messages = self.client.get_chat_history(chat_id, limit).await?;
let messages = messages.iter().map(CoreMessage::from).collect();
Ok(messages)
}
pub async fn send_text_message(
&mut self,
chat_id: ChatId,
text: String,
reply_to_message_id: Option<MessageId>,
reply: Option<ReplyInfo>,
) -> Result<CoreMessage, String> {
let message = self
.client
.send_message(chat_id, text, reply_to_message_id, reply)
.await?;
let message = CoreMessage::from(&message);
self.enqueue_event(CoreEvent::MessageAdded { chat_id, message: message.clone() });
Ok(message)
}
pub async fn edit_text_message(
&mut self,
chat_id: ChatId,
message_id: MessageId,
text: String,
) -> Result<CoreMessage, String> {
let message = self.client.edit_message(chat_id, message_id, text).await?;
let message = CoreMessage::from(&message);
self.enqueue_event(CoreEvent::MessageUpdated { chat_id, message: message.clone() });
Ok(message)
}
pub async fn delete_messages(
&mut self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String> {
self.client
.delete_messages(chat_id, message_ids.clone(), revoke)
.await?;
self.enqueue_event(CoreEvent::MessageDeleted { chat_id, message_ids });
Ok(())
}
pub async fn forward_messages(
&mut self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> Result<(), String> {
self.client
.forward_messages(to_chat_id, from_chat_id, message_ids)
.await
}
pub async fn toggle_reaction(
&mut self,
chat_id: ChatId,
message_id: MessageId,
reaction: String,
) -> Result<Vec<CoreReaction>, String> {
self.client
.toggle_reaction(chat_id, message_id, reaction)
.await?;
let reactions: Vec<CoreReaction> = self
.client
.get_chat_history(chat_id, i32::MAX)
.await?
.into_iter()
.find(|message| message.id() == message_id)
.map(|message| message.reactions().iter().map(CoreReaction::from).collect())
.unwrap_or_default();
self.enqueue_event(CoreEvent::ReactionChanged {
chat_id,
message_id,
reactions: reactions.clone(),
});
Ok(reactions)
}
pub async fn download_photo(&self, file_id: i32) -> Result<CoreDownloadedFile, String> {
self.client
.download_file(file_id)
.await
.map(|path| CoreDownloadedFile { file_id, path })
}
pub async fn download_voice(&self, file_id: i32) -> Result<CoreDownloadedFile, String> {
self.client
.download_voice_note(file_id)
.await
.map(|path| CoreDownloadedFile { file_id, path })
}
pub async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<CoreSearchResult>, String> {
let messages = self.client.search_messages(chat_id, query).await?;
Ok(messages
.iter()
.map(|message| CoreSearchResult { chat_id, message: CoreMessage::from(message) })
.collect())
}
pub async fn pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<CoreMessage>, String> {
self.client
.get_pinned_messages(chat_id)
.await
.map(|messages| messages.iter().map(CoreMessage::from).collect())
}
pub async fn copy_payload(
&mut self,
chat_id: ChatId,
message_id: MessageId,
) -> Result<String, String> {
self.client
.get_chat_history(chat_id, i32::MAX)
.await?
.into_iter()
.find(|message| message.id() == message_id)
.map(|message| message.text().to_string())
.ok_or_else(|| "message not found".to_string())
}
pub async fn open_profile(&mut self, chat_id: ChatId) -> Result<CoreProfile, String> {
let profile = self
.client
.get_profile_info(chat_id)
.await
.map(|profile| CoreProfile::from(&profile))?;
self.enqueue_event(CoreEvent::ProfileLoaded(profile.clone()));
Ok(profile)
}
pub async fn leave_chat(&mut self, chat_id: ChatId) -> Result<(), String> {
self.client.leave_chat(chat_id).await
}
pub async fn set_draft(&mut self, chat_id: ChatId, text: String) -> Result<(), String> {
self.client.set_draft_message(chat_id, text.clone()).await?;
self.enqueue_event(CoreEvent::DraftChanged(CoreDraft { chat_id, text }));
Ok(())
}
pub fn drain_client_events(&mut self) -> Vec<CoreEvent> {
let events: Vec<_> = self
.client
.drain_incoming_message_events()
.into_iter()
.map(|event| {
CoreEvent::IncomingNotificationCandidate(CoreNotificationCandidate {
chat: CoreChatSummary::from(&event.chat),
message: CoreMessage::from(&event.message),
sender_name: event.sender_name,
})
})
.collect();
self.events.extend(events.iter().cloned());
events
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreAccount {
pub id: String,
pub display_name: String,
pub is_active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreAuthState {
WaitTdlibParameters,
WaitPhoneNumber,
WaitCode,
WaitPassword,
Ready,
Closed,
Error { message: String },
}
impl From<&AuthState> for CoreAuthState {
fn from(value: &AuthState) -> Self {
match value {
AuthState::WaitTdlibParameters => Self::WaitTdlibParameters,
AuthState::WaitPhoneNumber => Self::WaitPhoneNumber,
AuthState::WaitCode => Self::WaitCode,
AuthState::WaitPassword => Self::WaitPassword,
AuthState::Ready => Self::Ready,
AuthState::Closed => Self::Closed,
AuthState::Error(message) => Self::Error { message: message.clone() },
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreChatSummary {
pub id: ChatId,
pub title: String,
pub username: Option<String>,
pub last_message: String,
pub last_message_date: i32,
pub unread_count: i32,
pub unread_mention_count: i32,
pub is_pinned: bool,
pub order: i64,
pub last_read_outbox_message_id: MessageId,
pub folder_ids: Vec<i32>,
pub is_muted: bool,
pub draft: Option<CoreDraft>,
}
impl From<&ChatInfo> for CoreChatSummary {
fn from(value: &ChatInfo) -> Self {
Self {
id: value.id,
title: value.title.clone(),
username: value.username.clone(),
last_message: value.last_message.clone(),
last_message_date: value.last_message_date,
unread_count: value.unread_count,
unread_mention_count: value.unread_mention_count,
is_pinned: value.is_pinned,
order: value.order,
last_read_outbox_message_id: value.last_read_outbox_message_id,
folder_ids: value.folder_ids.clone(),
is_muted: value.is_muted,
draft: value
.draft_text
.as_ref()
.map(|text| CoreDraft { chat_id: value.id, text: text.clone() }),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreFolder {
pub id: i32,
pub name: String,
}
impl From<&FolderInfo> for CoreFolder {
fn from(value: &FolderInfo) -> Self {
Self { id: value.id, name: value.name.clone() }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreMessage {
pub id: MessageId,
pub sender_name: String,
pub date: i32,
pub edit_date: Option<i32>,
pub media_album_id: Option<i64>,
pub text: String,
pub media: Option<CoreMedia>,
pub is_outgoing: bool,
pub is_read: bool,
pub can_be_edited: bool,
pub can_be_deleted_only_for_self: bool,
pub can_be_deleted_for_all_users: bool,
pub reply: Option<CoreReply>,
pub forward: Option<CoreForward>,
pub reactions: Vec<CoreReaction>,
}
impl From<&MessageInfo> for CoreMessage {
fn from(value: &MessageInfo) -> Self {
Self {
id: value.id(),
sender_name: value.sender_name().to_string(),
date: value.date(),
edit_date: value.is_edited().then_some(value.metadata.edit_date),
media_album_id: (value.media_album_id() != 0).then_some(value.media_album_id()),
text: value.text().to_string(),
media: value.content.media.as_ref().map(CoreMedia::from),
is_outgoing: value.is_outgoing(),
is_read: value.is_read(),
can_be_edited: value.can_be_edited(),
can_be_deleted_only_for_self: value.can_be_deleted_only_for_self(),
can_be_deleted_for_all_users: value.can_be_deleted_for_all_users(),
reply: value.reply_to().map(CoreReply::from),
forward: value.forward_from().map(CoreForward::from),
reactions: value.reactions().iter().map(CoreReaction::from).collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreReply {
pub message_id: MessageId,
pub sender_name: String,
pub text: String,
}
impl From<&ReplyInfo> for CoreReply {
fn from(value: &ReplyInfo) -> Self {
Self {
message_id: value.message_id,
sender_name: value.sender_name.clone(),
text: value.text.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreForward {
pub sender_name: String,
}
impl From<&ForwardInfo> for CoreForward {
fn from(value: &ForwardInfo) -> Self {
Self { sender_name: value.sender_name.clone() }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreReaction {
pub emoji: String,
pub count: i32,
pub is_chosen: bool,
}
impl From<&crate::tdlib::types::ReactionInfo> for CoreReaction {
fn from(value: &crate::tdlib::types::ReactionInfo) -> Self {
Self {
emoji: value.emoji.clone(),
count: value.count,
is_chosen: value.is_chosen,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreMedia {
Photo(CorePhoto),
Voice(CoreVoice),
}
impl From<&MediaInfo> for CoreMedia {
fn from(value: &MediaInfo) -> Self {
match value {
MediaInfo::Photo(photo) => Self::Photo(CorePhoto {
file_id: photo.file_id,
width: photo.width,
height: photo.height,
download_state: CoreDownloadState::from(&photo.download_state),
}),
MediaInfo::Voice(voice) => Self::Voice(CoreVoice {
file_id: voice.file_id,
duration: voice.duration,
mime_type: voice.mime_type.clone(),
waveform: voice.waveform.clone(),
download_state: CoreDownloadState::from(&voice.download_state),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CorePhoto {
pub file_id: i32,
pub width: i32,
pub height: i32,
pub download_state: CoreDownloadState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreVoice {
pub file_id: i32,
pub duration: i32,
pub mime_type: String,
pub waveform: String,
pub download_state: CoreDownloadState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreDownloadState {
NotDownloaded,
Downloading,
Downloaded { path: String },
Error { message: String },
}
impl From<&crate::tdlib::PhotoDownloadState> for CoreDownloadState {
fn from(value: &crate::tdlib::PhotoDownloadState) -> Self {
match value {
crate::tdlib::PhotoDownloadState::NotDownloaded => Self::NotDownloaded,
crate::tdlib::PhotoDownloadState::Downloading => Self::Downloading,
crate::tdlib::PhotoDownloadState::Downloaded(path) => {
Self::Downloaded { path: path.clone() }
}
crate::tdlib::PhotoDownloadState::Error(message) => {
Self::Error { message: message.clone() }
}
}
}
}
impl From<&crate::tdlib::VoiceDownloadState> for CoreDownloadState {
fn from(value: &crate::tdlib::VoiceDownloadState) -> Self {
match value {
crate::tdlib::VoiceDownloadState::NotDownloaded => Self::NotDownloaded,
crate::tdlib::VoiceDownloadState::Downloading => Self::Downloading,
crate::tdlib::VoiceDownloadState::Downloaded(path) => {
Self::Downloaded { path: path.clone() }
}
crate::tdlib::VoiceDownloadState::Error(message) => {
Self::Error { message: message.clone() }
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreProfile {
pub chat_id: ChatId,
pub title: String,
pub username: Option<String>,
pub bio: Option<String>,
pub phone_number: Option<String>,
pub chat_type: String,
pub member_count: Option<i32>,
pub description: Option<String>,
pub invite_link: Option<String>,
pub is_group: bool,
pub online_status: Option<String>,
}
impl From<&ProfileInfo> for CoreProfile {
fn from(value: &ProfileInfo) -> Self {
Self {
chat_id: value.chat_id,
title: value.title.clone(),
username: value.username.clone(),
bio: value.bio.clone(),
phone_number: value.phone_number.clone(),
chat_type: value.chat_type.clone(),
member_count: value.member_count,
description: value.description.clone(),
invite_link: value.invite_link.clone(),
is_group: value.is_group,
online_status: value.online_status.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreDraft {
pub chat_id: ChatId,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreSearchResult {
pub chat_id: ChatId,
pub message: CoreMessage,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreDownloadedFile {
pub file_id: i32,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreNetworkState {
WaitingForNetwork,
ConnectingToProxy,
Connecting,
Updating,
Ready,
}
impl From<&NetworkState> for CoreNetworkState {
fn from(value: &NetworkState) -> Self {
match value {
NetworkState::WaitingForNetwork => Self::WaitingForNetwork,
NetworkState::ConnectingToProxy => Self::ConnectingToProxy,
NetworkState::Connecting => Self::Connecting,
NetworkState::Updating => Self::Updating,
NetworkState::Ready => Self::Ready,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreTypingState {
Idle,
Typing {
chat_id: ChatId,
user_id: UserId,
text: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreNotificationCandidate {
pub chat: CoreChatSummary,
pub message: CoreMessage,
pub sender_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreEvent {
AuthChanged(CoreAuthState),
ChatListChanged(Vec<CoreChatSummary>),
FolderListChanged(Vec<CoreFolder>),
MessageAdded {
chat_id: ChatId,
message: CoreMessage,
},
MessageUpdated {
chat_id: ChatId,
message: CoreMessage,
},
MessageDeleted {
chat_id: ChatId,
message_ids: Vec<MessageId>,
},
ReactionChanged {
chat_id: ChatId,
message_id: MessageId,
reactions: Vec<CoreReaction>,
},
MediaDownloadProgress {
file_id: i32,
downloaded_size: i64,
total_size: i64,
},
IncomingNotificationCandidate(CoreNotificationCandidate),
NetworkChanged(CoreNetworkState),
TypingChanged(CoreTypingState),
DraftChanged(CoreDraft),
ProfileLoaded(CoreProfile),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tdlib::types::ReactionInfo;
use crate::tdlib::{
AuthState, ChatInfo, FolderInfo, MessageBuilder, NetworkState, ProfileInfo,
};
use crate::test_support::FakeTdClient;
use crate::types::{ChatId, MessageId};
fn sample_chat() -> ChatInfo {
ChatInfo {
id: ChatId::new(42),
title: "Team".to_string(),
username: Some("team_chat".to_string()),
last_message: "Latest".to_string(),
last_message_date: 1_700_000_000,
unread_count: 3,
unread_mention_count: 1,
is_pinned: true,
order: 99,
last_read_outbox_message_id: MessageId::new(7),
folder_ids: vec![0, 2],
is_muted: true,
draft_text: Some("Draft".to_string()),
}
}
#[test]
fn auth_state_mapping_is_stable_for_swift() {
assert_eq!(
CoreAuthState::from(&AuthState::WaitPhoneNumber),
CoreAuthState::WaitPhoneNumber
);
assert_eq!(CoreAuthState::from(&AuthState::WaitCode), CoreAuthState::WaitCode);
assert_eq!(CoreAuthState::from(&AuthState::WaitPassword), CoreAuthState::WaitPassword);
assert_eq!(CoreAuthState::from(&AuthState::Ready), CoreAuthState::Ready);
assert_eq!(
CoreAuthState::from(&AuthState::Error("bad code".to_string())),
CoreAuthState::Error { message: "bad code".to_string() }
);
}
#[test]
fn chat_summary_preserves_ios_relevant_state() {
let chat = CoreChatSummary::from(&sample_chat());
assert_eq!(chat.id, ChatId::new(42));
assert_eq!(chat.title, "Team");
assert_eq!(chat.username.as_deref(), Some("team_chat"));
assert_eq!(chat.last_message, "Latest");
assert_eq!(chat.unread_count, 3);
assert_eq!(chat.unread_mention_count, 1);
assert!(chat.is_pinned);
assert!(chat.is_muted);
assert_eq!(chat.folder_ids, vec![0, 2]);
assert_eq!(chat.draft.as_ref().map(|draft| draft.text.as_str()), Some("Draft"));
}
#[test]
fn message_mapping_preserves_reply_reactions_and_state() {
let message = MessageBuilder::new(MessageId::new(100))
.sender_name("Alice")
.text("Hello")
.date(1_700_000_001)
.edit_date(1_700_000_002)
.reply_to(crate::tdlib::ReplyInfo {
message_id: MessageId::new(90),
sender_name: "Bob".to_string(),
text: "Original".to_string(),
})
.reactions(vec![ReactionInfo {
emoji: "👍".to_string(), count: 2, is_chosen: true
}])
.outgoing()
.read()
.build();
let mapped = CoreMessage::from(&message);
assert_eq!(mapped.id, MessageId::new(100));
assert_eq!(mapped.sender_name, "Alice");
assert_eq!(mapped.text, "Hello");
assert!(mapped.is_outgoing);
assert!(mapped.is_read);
assert_eq!(mapped.edit_date, Some(1_700_000_002));
assert_eq!(mapped.reply.as_ref().map(|reply| reply.message_id), Some(MessageId::new(90)));
assert_eq!(mapped.reactions[0].emoji, "👍");
assert!(mapped.reactions[0].is_chosen);
}
#[test]
fn session_event_queue_drains_in_fifo_order() {
let mut session = CoreSession::new(());
session.enqueue_event(CoreEvent::AuthChanged(CoreAuthState::WaitCode));
session.enqueue_event(CoreEvent::NetworkChanged(CoreNetworkState::Ready));
assert_eq!(
session.poll_events(),
vec![
CoreEvent::AuthChanged(CoreAuthState::WaitCode),
CoreEvent::NetworkChanged(CoreNetworkState::Ready),
]
);
assert!(session.poll_events().is_empty());
}
#[test]
fn session_drains_incoming_message_events_as_notification_candidates() {
let chat = sample_chat();
let client = FakeTdClient::new().with_chat(chat.clone());
client.simulate_incoming_message(chat.id, "Ping".to_string(), "Alice");
let mut session = CoreSession::new(client);
let events = session.drain_client_events();
assert_eq!(events.len(), 1);
let CoreEvent::IncomingNotificationCandidate(candidate) = &events[0] else {
panic!("expected incoming notification candidate");
};
assert_eq!(candidate.chat.id, chat.id);
assert_eq!(candidate.message.text, "Ping");
assert_eq!(candidate.sender_name, "Alice");
assert_eq!(session.poll_events(), events);
}
#[test]
fn events_cover_chat_message_profile_and_folder_shapes() {
let chat = CoreChatSummary::from(&sample_chat());
let message = CoreMessage::from(
&MessageBuilder::new(MessageId::new(10))
.sender_name("Alice")
.text("Hi")
.build(),
);
let folder = CoreFolder::from(&FolderInfo { id: 2, name: "Work".to_string() });
let profile = CoreProfile::from(&ProfileInfo {
chat_id: ChatId::new(42),
title: "Team".to_string(),
username: Some("team_chat".to_string()),
bio: None,
phone_number: None,
chat_type: "Group".to_string(),
member_count: Some(10),
description: Some("Project group".to_string()),
invite_link: None,
is_group: true,
online_status: None,
});
assert_eq!(
CoreEvent::ChatListChanged(vec![chat.clone()]),
CoreEvent::ChatListChanged(vec![chat])
);
assert_eq!(
CoreEvent::MessageAdded { chat_id: ChatId::new(42), message: message.clone() },
CoreEvent::MessageAdded { chat_id: ChatId::new(42), message }
);
assert_eq!(folder.name, "Work");
assert_eq!(profile.member_count, Some(10));
assert_eq!(
CoreNetworkState::from(&NetworkState::WaitingForNetwork),
CoreNetworkState::WaitingForNetwork
);
assert_eq!(
CoreTypingState::Typing {
chat_id: ChatId::new(42),
user_id: UserId::new(7),
text: "typing".to_string(),
},
CoreTypingState::Typing {
chat_id: ChatId::new(42),
user_id: UserId::new(7),
text: "typing".to_string(),
}
);
}
#[tokio::test]
async fn facade_methods_enqueue_state_profile_and_draft_events() {
let profile = ProfileInfo {
chat_id: ChatId::new(42),
title: "Team".to_string(),
username: Some("team_chat".to_string()),
bio: None,
phone_number: None,
chat_type: "Group".to_string(),
member_count: Some(10),
description: None,
invite_link: None,
is_group: true,
online_status: None,
};
let client = FakeTdClient::new()
.with_auth_state(AuthState::WaitPassword)
.with_network_state(NetworkState::Connecting)
.with_profile(42, profile);
let mut session = CoreSession::new(client);
session.emit_auth_state();
session.emit_network_state();
let loaded_profile = session.open_profile(ChatId::new(42)).await.unwrap();
session.leave_chat(ChatId::new(42)).await.unwrap();
session
.set_draft(ChatId::new(42), "Later".to_string())
.await
.unwrap();
assert_eq!(loaded_profile.title, "Team");
assert_eq!(
session.poll_events(),
vec![
CoreEvent::AuthChanged(CoreAuthState::WaitPassword),
CoreEvent::NetworkChanged(CoreNetworkState::Connecting),
CoreEvent::ProfileLoaded(CoreProfile {
chat_id: ChatId::new(42),
title: "Team".to_string(),
username: Some("team_chat".to_string()),
bio: None,
phone_number: None,
chat_type: "Group".to_string(),
member_count: Some(10),
description: None,
invite_link: None,
is_group: true,
online_status: None,
}),
CoreEvent::DraftChanged(CoreDraft {
chat_id: ChatId::new(42),
text: "Later".to_string(),
}),
]
);
}
#[tokio::test]
async fn message_mutations_return_models_and_enqueue_events() {
let chat_id = ChatId::new(42);
let original = MessageBuilder::new(MessageId::new(10))
.sender_name("Me")
.text("Before")
.outgoing()
.build();
let client = FakeTdClient::new()
.with_chat(sample_chat())
.with_message(chat_id.as_i64(), original);
let mut session = CoreSession::new(client);
let sent = session
.send_text_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let edited = session
.edit_text_message(chat_id, MessageId::new(10), "After".to_string())
.await
.unwrap();
let copied = session
.copy_payload(chat_id, MessageId::new(10))
.await
.unwrap();
session
.delete_messages(chat_id, vec![MessageId::new(10)], true)
.await
.unwrap();
assert_eq!(sent.text, "Hello");
assert_eq!(edited.text, "After");
assert_eq!(copied, "After");
assert_eq!(
session.poll_events(),
vec![
CoreEvent::MessageAdded { chat_id, message: sent },
CoreEvent::MessageUpdated { chat_id, message: edited },
CoreEvent::MessageDeleted { chat_id, message_ids: vec![MessageId::new(10)] },
]
);
}
#[tokio::test]
async fn pinned_messages_are_mapped_for_native_clients() {
let chat_id = ChatId::new(42);
let pinned = MessageBuilder::new(MessageId::new(10))
.sender_name("Alice")
.text("Pinned")
.build();
let mut client = FakeTdClient::new();
client.set_current_pinned_message(Some(pinned));
let mut session = CoreSession::new(client);
let pinned = session.pinned_messages(chat_id).await.unwrap();
assert_eq!(pinned.len(), 1);
assert_eq!(pinned[0].id, MessageId::new(10));
assert_eq!(pinned[0].text, "Pinned");
}
#[tokio::test]
async fn facade_delegates_auth_forward_reactions_and_downloads() {
let chat_id = ChatId::new(42);
let other_chat_id = ChatId::new(100);
let message = MessageBuilder::new(MessageId::new(10))
.sender_name("Alice")
.text("React here")
.build();
let client = FakeTdClient::new()
.with_message(chat_id.as_i64(), message)
.with_downloaded_file(77, "/tmp/photo.jpg")
.with_downloaded_file(88, "/tmp/voice.ogg");
let mut session = CoreSession::new(client);
session
.send_phone_number("+10000000000".to_string())
.await
.unwrap();
session.send_code("12345".to_string()).await.unwrap();
session.send_password("secret".to_string()).await.unwrap();
session
.forward_messages(other_chat_id, chat_id, vec![MessageId::new(10)])
.await
.unwrap();
let reactions = session
.toggle_reaction(chat_id, MessageId::new(10), "👍".to_string())
.await
.unwrap();
let downloaded = session.download_photo(77).await.unwrap();
let downloaded_voice = session.download_voice(88).await.unwrap();
assert_eq!(downloaded.path, "/tmp/photo.jpg");
assert_eq!(downloaded_voice.path, "/tmp/voice.ogg");
assert_eq!(session.client().get_forwarded_messages().len(), 1);
assert_eq!(
reactions,
vec![CoreReaction {
emoji: "👍".to_string(), count: 1, is_chosen: true
}]
);
assert_eq!(
session.poll_events(),
vec![CoreEvent::ReactionChanged { chat_id, message_id: MessageId::new(10), reactions }]
);
}
}

View File

@@ -1,5 +1,5 @@
use crate::types::{ChatId, MessageId, UserId};
use std::env;
use std::collections::VecDeque;
use std::path::PathBuf;
use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
use tdlib_rs::functions;
@@ -13,7 +13,25 @@ use super::types::{
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
};
use super::users::UserCache;
use crate::notifications::NotificationManager;
#[derive(Debug, Clone)]
pub struct TdCredentials {
pub api_id: i32,
pub api_hash: String,
}
#[derive(Debug, Clone)]
pub struct TdClientConfig {
pub credentials: TdCredentials,
pub db_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct IncomingMessageEvent {
pub chat: ChatInfo,
pub message: MessageInfo,
pub sender_name: String,
}
/// TDLib client wrapper for Telegram integration.
///
@@ -28,9 +46,15 @@ use crate::notifications::NotificationManager;
/// # Examples
///
/// ```ignore
/// use tele_tui::tdlib::TdClient;
/// use tele_core::tdlib::TdClient;
///
/// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data"));
/// let mut client = TdClient::new(tele_core::tdlib::TdClientConfig {
/// credentials: tele_core::tdlib::TdCredentials {
/// api_id: 123,
/// api_hash: "hash".to_string(),
/// },
/// db_path: std::path::PathBuf::from("tdlib_data"),
/// });
///
/// // Start authorization
/// client.send_phone_number("+1234567890".to_string()).await?;
@@ -52,7 +76,7 @@ pub struct TdClient {
pub message_manager: MessageManager,
pub user_cache: UserCache,
pub reaction_manager: ReactionManager,
pub notification_manager: NotificationManager,
incoming_message_events: VecDeque<IncomingMessageEvent>,
// Состояние сети
pub network_state: NetworkState,
@@ -62,62 +86,41 @@ pub struct TdClient {
impl TdClient {
/// Creates a new TDLib client instance.
///
/// Reads API credentials from:
/// 1. ~/.config/tele-tui/credentials file
/// 2. Environment variables `API_ID` and `API_HASH` (fallback)
///
/// Initializes all managers and sets initial network state to Connecting.
///
/// # Returns
///
/// A new `TdClient` instance ready for authentication.
pub fn new(db_path: PathBuf) -> Self {
// Пробуем загрузить credentials из Config (файл или env)
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
// Fallback на прямое чтение из env (старое поведение)
let api_id = env::var("API_ID")
.unwrap_or_else(|_| "0".to_string())
.parse()
.unwrap_or(0);
let api_hash = env::var("API_HASH").unwrap_or_default();
(api_id, api_hash)
});
pub fn new(config: TdClientConfig) -> Self {
let client_id = tdlib_rs::create_client();
Self {
api_id,
api_hash,
db_path,
api_id: config.credentials.api_id,
api_hash: config.credentials.api_hash,
db_path: config.db_path,
client_id,
auth: AuthManager::new(client_id),
chat_manager: ChatManager::new(client_id),
message_manager: MessageManager::new(client_id),
user_cache: UserCache::new(client_id),
reaction_manager: ReactionManager::new(client_id),
notification_manager: NotificationManager::new(),
incoming_message_events: VecDeque::new(),
network_state: NetworkState::Connecting,
}
}
/// Configures notification manager from app config
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
self.notification_manager.set_enabled(config.enabled);
self.notification_manager
.set_only_mentions(config.only_mentions);
self.notification_manager
.set_show_preview(config.show_preview);
self.notification_manager.set_timeout(config.timeout_ms);
self.notification_manager
.set_urgency(config.urgency.clone());
pub fn enqueue_incoming_message_event(
&mut self,
chat: ChatInfo,
message: MessageInfo,
sender_name: String,
) {
self.incoming_message_events
.push_back(IncomingMessageEvent { chat, message, sender_name });
}
/// Synchronizes muted chats from Telegram to notification manager.
///
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
pub fn sync_notification_muted_chats(&mut self) {
self.notification_manager
.sync_muted_chats(&self.chat_manager.chats);
pub fn drain_incoming_message_events(&mut self) -> Vec<IncomingMessageEvent> {
self.incoming_message_events.drain(..).collect()
}
// Делегирование к auth
@@ -791,7 +794,13 @@ impl TdClient {
let _ = functions::close(self.client_id).await;
// 2. Create new client
let new_client = TdClient::new(db_path);
let new_client = TdClient::new(TdClientConfig {
credentials: TdCredentials {
api_id: self.api_id,
api_hash: self.api_hash.clone(),
},
db_path,
});
// 3. Spawn set_tdlib_parameters for new client
let new_client_id = new_client.client_id;

View File

@@ -5,7 +5,7 @@
use super::client::TdClient;
use super::r#trait::{
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient,
MessageClient, ReactionClient, UpdateClient, UserClient,
};
use super::{
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
@@ -313,17 +313,6 @@ impl ClientState for TdClient {
}
}
impl NotificationClient for TdClient {
fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
self.configure_notifications(config);
}
fn sync_notification_muted_chats(&mut self) {
self.notification_manager
.sync_muted_chats(&self.chat_manager.chats);
}
}
#[async_trait]
impl AccountClient for TdClient {
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
@@ -336,4 +325,8 @@ impl UpdateClient for TdClient {
// Delegate to the real implementation
TdClient::handle_update(self, update)
}
fn drain_incoming_message_events(&mut self) -> Vec<super::IncomingMessageEvent> {
TdClient::drain_incoming_message_events(self)
}
}

View File

@@ -4,7 +4,7 @@ mod chat_helpers; // Chat management helpers
pub mod chats;
pub mod client;
mod client_impl; // Private module for trait implementation
pub(crate) mod message_conversion; // Message conversion utilities (for messages.rs)
pub mod message_conversion; // Message conversion utilities (for messages.rs)
mod message_converter; // Message conversion utilities (for client.rs)
pub mod messages;
pub mod reactions;
@@ -19,7 +19,7 @@ pub use client::TdClient;
#[allow(unused_imports)]
pub use r#trait::{
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
MessageClient, NotificationClient, ReactionClient, TdClientTrait, UpdateClient, UserClient,
MessageClient, ReactionClient, TdClientTrait, UpdateClient, UserClient,
};
#[allow(unused_imports)]
pub use types::{
@@ -28,6 +28,7 @@ pub use types::{
VoiceDownloadState, VoiceInfo,
};
pub use client::{IncomingMessageEvent, TdClientConfig, TdCredentials};
#[cfg(feature = "images")]
pub use types::ImageModalState;
pub use users::UserCache;

View File

@@ -3,7 +3,10 @@
//! This trait allows tests to use FakeTdClient instead of real TDLib client.
#![allow(dead_code)]
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
use crate::tdlib::{
AuthState, FolderInfo, IncomingMessageEvent, MessageInfo, ProfileInfo, UserCache,
UserOnlineStatus,
};
use crate::types::{ChatId, MessageId, UserId};
use async_trait::async_trait;
use std::borrow::Cow;
@@ -163,12 +166,6 @@ pub trait ClientState: Send {
fn network_state(&self) -> super::types::NetworkState;
}
/// Notification configuration operations.
pub trait NotificationClient: Send {
fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig);
fn sync_notification_muted_chats(&mut self);
}
/// Account switching operations.
#[async_trait]
pub trait AccountClient: Send {
@@ -182,6 +179,7 @@ pub trait AccountClient: Send {
/// TDLib update routing.
pub trait UpdateClient: Send {
fn handle_update(&mut self, update: Update);
fn drain_incoming_message_events(&mut self) -> Vec<IncomingMessageEvent>;
}
/// Facade trait for TDLib client operations
@@ -198,7 +196,6 @@ pub trait TdClientTrait:
+ ReactionClient
+ FileClient
+ ClientState
+ NotificationClient
+ AccountClient
+ UpdateClient
+ Send
@@ -214,7 +211,6 @@ impl<T> TdClientTrait for T where
+ ReactionClient
+ FileClient
+ ClientState
+ NotificationClient
+ AccountClient
+ UpdateClient
+ Send

View File

@@ -312,8 +312,8 @@ impl MessageInfo {
/// # Примеры
///
/// ```
/// use tele_tui::tdlib::MessageBuilder;
/// use tele_tui::types::MessageId;
/// use tele_core::tdlib::MessageBuilder;
/// use tele_core::types::MessageId;
///
/// let message = MessageBuilder::new(MessageId::new(123))
/// .sender_name("Alice")

View File

@@ -27,12 +27,9 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
// Get sender name (from message or user cache)
let sender_name = msg_info.sender_name();
let sender_name = msg_info.sender_name().to_string();
// Send notification
let _ = client
.notification_manager
.notify_new_message(&chat, &msg_info, sender_name);
client.enqueue_incoming_message_event(chat, msg_info, sender_name);
}
return;
}

View File

@@ -0,0 +1,12 @@
// Fake TDLib client for testing.
mod builders;
mod inspect;
mod operations;
mod state;
#[allow(unused_imports)]
pub use state::{
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, PendingViewMessages,
SearchQuery, SentMessage, TdUpdate, ViewedMessages,
};

View File

@@ -0,0 +1,86 @@
use super::{FakeTdClient, TdUpdate};
use crate::tdlib::types::FolderInfo;
use crate::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo};
use tokio::sync::mpsc;
#[allow(dead_code)]
impl FakeTdClient {
/// Create an update channel for receiving simulated TDLib events.
pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver<TdUpdate>) {
let (tx, rx) = mpsc::unbounded_channel();
*self.update_tx.lock().unwrap() = Some(tx);
(self, rx)
}
/// Enable simulated delays, closer to real TDLib behavior.
pub fn with_delays(mut self) -> Self {
self.simulate_delays = true;
self
}
pub fn with_chat(self, chat: ChatInfo) -> Self {
self.chats.lock().unwrap().push(chat);
self
}
pub fn with_chats(self, chats: Vec<ChatInfo>) -> Self {
self.chats.lock().unwrap().extend(chats);
self
}
pub fn with_message(self, chat_id: i64, message: MessageInfo) -> Self {
self.messages
.lock()
.unwrap()
.entry(chat_id)
.or_default()
.push(message);
self
}
pub fn with_messages(self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
self.messages.lock().unwrap().insert(chat_id, messages);
self
}
pub fn with_folder(self, id: i32, name: &str) -> Self {
self.folders
.lock()
.unwrap()
.push(FolderInfo { id, name: name.to_string() });
self
}
pub fn with_user(self, id: i64, name: &str) -> Self {
self.user_names.lock().unwrap().insert(id, name.to_string());
self
}
pub fn with_profile(self, chat_id: i64, profile: ProfileInfo) -> Self {
self.profiles.lock().unwrap().insert(chat_id, profile);
self
}
pub fn with_network_state(self, state: NetworkState) -> Self {
*self.network_state.lock().unwrap() = state;
self
}
pub fn with_auth_state(self, state: AuthState) -> Self {
*self.auth_state.lock().unwrap() = state;
self
}
pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self {
self.downloaded_files
.lock()
.unwrap()
.insert(file_id, path.to_string());
self
}
pub fn with_available_reactions(self, reactions: Vec<String>) -> Self {
*self.available_reactions.lock().unwrap() = reactions;
self
}
}

View File

@@ -0,0 +1,92 @@
use super::{
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage,
TdUpdate,
};
use crate::tdlib::types::FolderInfo;
use crate::tdlib::{ChatInfo, MessageInfo, NetworkState};
use tokio::sync::mpsc;
#[allow(dead_code)]
impl FakeTdClient {
pub fn get_chats(&self) -> Vec<ChatInfo> {
self.chats.lock().unwrap().clone()
}
pub fn get_folders(&self) -> Vec<FolderInfo> {
self.folders.lock().unwrap().clone()
}
pub fn get_messages(&self, chat_id: i64) -> Vec<MessageInfo> {
self.messages
.lock()
.unwrap()
.get(&chat_id)
.cloned()
.unwrap_or_default()
}
pub fn get_sent_messages(&self) -> Vec<SentMessage> {
self.sent_messages.lock().unwrap().clone()
}
pub fn get_edited_messages(&self) -> Vec<EditedMessage> {
self.edited_messages.lock().unwrap().clone()
}
pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> {
self.deleted_messages.lock().unwrap().clone()
}
pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> {
self.forwarded_messages.lock().unwrap().clone()
}
pub fn get_search_queries(&self) -> Vec<SearchQuery> {
self.searched_queries.lock().unwrap().clone()
}
pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> {
self.viewed_messages.lock().unwrap().clone()
}
pub fn get_chat_actions(&self) -> Vec<(i64, String)> {
self.chat_actions.lock().unwrap().clone()
}
pub fn get_network_state(&self) -> NetworkState {
self.network_state.lock().unwrap().clone()
}
pub fn get_current_chat_id(&self) -> Option<i64> {
*self.current_chat_id.lock().unwrap()
}
pub fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
*self.current_pinned_message.lock().unwrap() = msg;
}
pub async fn process_pending_view_messages(&mut self) {
let mut pending = self.pending_view_messages.lock().unwrap();
for (chat_id, message_ids) in pending.drain(..) {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
self.viewed_messages
.lock()
.unwrap()
.push((chat_id.as_i64(), ids));
}
}
pub fn set_update_channel(&self, tx: mpsc::UnboundedSender<TdUpdate>) {
*self.update_tx.lock().unwrap() = Some(tx);
}
pub fn clear_all_history(&self) {
self.sent_messages.lock().unwrap().clear();
self.edited_messages.lock().unwrap().clear();
self.deleted_messages.lock().unwrap().clear();
self.forwarded_messages.lock().unwrap().clear();
self.searched_queries.lock().unwrap().clear();
self.viewed_messages.lock().unwrap().clear();
self.chat_actions.lock().unwrap().clear();
}
}

View File

@@ -0,0 +1,476 @@
use super::{
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage,
TdUpdate,
};
use crate::tdlib::types::ReactionInfo;
use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use crate::types::{ChatId, MessageId, UserId};
#[allow(dead_code)]
impl FakeTdClient {
pub async fn load_chats(&self, limit: usize) -> Result<Vec<ChatInfo>, String> {
if self.should_fail() {
return Err("Failed to load chats".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
let chats = self
.chats
.lock()
.unwrap()
.iter()
.take(limit)
.cloned()
.collect();
Ok(chats)
}
pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to open chat".to_string());
}
*self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64());
Ok(())
}
pub async fn get_chat_history(
&self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() {
return Err("Failed to load history".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
let messages = self
.messages
.lock()
.unwrap()
.get(&chat_id.as_i64())
.map(|msgs| msgs.iter().take(limit as usize).cloned().collect())
.unwrap_or_default();
Ok(messages)
}
pub async fn load_older_messages(
&self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() {
return Err("Failed to load older messages".to_string());
}
let messages = self.messages.lock().unwrap();
let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?;
if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) {
let older = chat_messages.iter().take(idx).cloned().collect();
Ok(older)
} else {
Ok(vec![])
}
}
pub async fn send_message(
&self,
chat_id: ChatId,
text: String,
reply_to: Option<MessageId>,
reply_info: Option<ReplyInfo>,
) -> Result<MessageInfo, String> {
if self.should_fail() {
return Err("Failed to send message".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
}
let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000);
self.sent_messages.lock().unwrap().push(SentMessage {
chat_id: chat_id.as_i64(),
text: text.clone(),
reply_to,
reply_info: reply_info.clone(),
});
let message = MessageInfo::new(
message_id,
"You".to_string(),
true,
text,
vec![],
chrono::Utc::now().timestamp() as i32,
0,
false,
true,
true,
true,
reply_info,
None,
vec![],
);
self.messages
.lock()
.unwrap()
.entry(chat_id.as_i64())
.or_default()
.push(message.clone());
self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message.clone()) });
Ok(message)
}
pub async fn edit_message(
&self,
chat_id: ChatId,
message_id: MessageId,
new_text: String,
) -> Result<MessageInfo, String> {
if self.should_fail() {
return Err("Failed to edit message".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
}
self.edited_messages.lock().unwrap().push(EditedMessage {
chat_id: chat_id.as_i64(),
message_id,
new_text: new_text.clone(),
});
let mut messages = self.messages.lock().unwrap();
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
msg.content.text = new_text.clone();
msg.metadata.edit_date = msg.metadata.date + 60;
let updated = msg.clone();
drop(messages);
self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text });
return Ok(updated);
}
}
Err("Message not found".to_string())
}
pub async fn delete_messages(
&self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to delete messages".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
self.deleted_messages.lock().unwrap().push(DeletedMessages {
chat_id: chat_id.as_i64(),
message_ids: message_ids.clone(),
revoke,
});
let mut messages = self.messages.lock().unwrap();
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
chat_msgs.retain(|m| !message_ids.contains(&m.id()));
}
drop(messages);
self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids });
Ok(())
}
pub async fn forward_messages(
&self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to forward messages".to_string());
}
if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
}
self.forwarded_messages
.lock()
.unwrap()
.push(ForwardedMessages {
from_chat_id: from_chat_id.as_i64(),
to_chat_id: to_chat_id.as_i64(),
message_ids,
});
Ok(())
}
pub async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() {
return Err("Failed to search messages".to_string());
}
let messages = self.messages.lock().unwrap();
let results: Vec<_> = messages
.get(&chat_id.as_i64())
.map(|msgs| {
msgs.iter()
.filter(|m| m.text().to_lowercase().contains(&query.to_lowercase()))
.cloned()
.collect()
})
.unwrap_or_default();
self.searched_queries.lock().unwrap().push(SearchQuery {
chat_id: chat_id.as_i64(),
query: query.to_string(),
results_count: results.len(),
});
Ok(results)
}
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
if text.is_empty() {
self.drafts.lock().unwrap().remove(&chat_id.as_i64());
} else {
self.drafts
.lock()
.unwrap()
.insert(chat_id.as_i64(), text.clone());
}
self.send_update(TdUpdate::ChatDraftMessage {
chat_id,
draft_text: if text.is_empty() { None } else { Some(text) },
});
Ok(())
}
pub async fn send_chat_action(&self, chat_id: ChatId, action: String) {
self.chat_actions
.lock()
.unwrap()
.push((chat_id.as_i64(), action.clone()));
if action == "Typing" {
*self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64());
} else if action == "Cancel" {
*self.typing_chat_id.lock().unwrap() = None;
}
}
pub async fn get_message_available_reactions(
&self,
_chat_id: ChatId,
_message_id: MessageId,
) -> Result<Vec<String>, String> {
if self.should_fail() {
return Err("Failed to get available reactions".to_string());
}
Ok(self.available_reactions.lock().unwrap().clone())
}
pub async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
emoji: String,
) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to toggle reaction".to_string());
}
let mut messages = self.messages.lock().unwrap();
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
let reactions = &mut msg.interactions.reactions;
if let Some(pos) = reactions
.iter()
.position(|reaction| reaction.emoji == emoji && reaction.is_chosen)
{
reactions.remove(pos);
} else if let Some(reaction) = reactions
.iter_mut()
.find(|reaction| reaction.emoji == emoji)
{
reaction.is_chosen = true;
reaction.count += 1;
} else {
reactions.push(ReactionInfo {
emoji: emoji.clone(),
count: 1,
is_chosen: true,
});
}
let updated_reactions = reactions.clone();
drop(messages);
self.send_update(TdUpdate::MessageInteractionInfo {
chat_id,
message_id,
reactions: updated_reactions,
});
}
}
Ok(())
}
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
if self.should_fail() {
return Err("Failed to download file".to_string());
}
self.downloaded_files
.lock()
.unwrap()
.get(&file_id)
.cloned()
.ok_or_else(|| format!("File {} not found", file_id))
}
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
if self.should_fail() {
return Err("Failed to get profile info".to_string());
}
self.profiles
.lock()
.unwrap()
.get(&chat_id.as_i64())
.cloned()
.ok_or_else(|| "Profile not found".to_string())
}
pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec<MessageId>) {
self.viewed_messages
.lock()
.unwrap()
.push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect()));
}
pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> {
if self.should_fail() {
return Err("Failed to load folder chats".to_string());
}
Ok(())
}
fn send_update(&self, update: TdUpdate) {
if let Some(tx) = self.update_tx.lock().unwrap().as_ref() {
let _ = tx.send(update);
}
}
fn should_fail(&self) -> bool {
let mut fail = self.fail_next_operation.lock().unwrap();
if *fail {
*fail = false;
true
} else {
false
}
}
pub fn fail_next(&self) {
*self.fail_next_operation.lock().unwrap() = true;
}
pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) {
let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp());
let message = MessageInfo::new(
message_id,
sender_name.to_string(),
false,
text,
vec![],
chrono::Utc::now().timestamp() as i32,
0,
false,
false,
false,
true,
None,
None,
vec![],
);
self.messages
.lock()
.unwrap()
.entry(chat_id.as_i64())
.or_default()
.push(message.clone());
if let Some(chat) = self
.chats
.lock()
.unwrap()
.iter()
.find(|chat| chat.id == chat_id)
.cloned()
{
self.incoming_message_events
.lock()
.unwrap()
.push(crate::tdlib::IncomingMessageEvent {
chat,
message: message.clone(),
sender_name: sender_name.to_string(),
});
}
self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) });
}
pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) {
self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() });
}
pub fn simulate_network_change(&self, state: crate::tdlib::NetworkState) {
*self.network_state.lock().unwrap() = state.clone();
self.send_update(TdUpdate::ConnectionState { state });
}
pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) {
self.send_update(TdUpdate::ChatReadOutbox {
chat_id,
last_read_outbox_message_id: last_read_message_id,
});
}
}

View File

@@ -0,0 +1,206 @@
use crate::tdlib::types::{FolderInfo, ReactionInfo};
use crate::tdlib::{
AuthState, ChatInfo, IncomingMessageEvent, MessageInfo, NetworkState, ProfileInfo, ReplyInfo,
};
use crate::types::{ChatId, MessageId, UserId};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
pub type ViewedMessages = Vec<(i64, Vec<i64>)>;
pub type PendingViewMessages = Vec<(ChatId, Vec<MessageId>)>;
/// Update events from TDLib, simplified for tests.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum TdUpdate {
NewMessage {
chat_id: ChatId,
message: Box<MessageInfo>,
},
MessageContent {
chat_id: ChatId,
message_id: MessageId,
new_text: String,
},
DeleteMessages {
chat_id: ChatId,
message_ids: Vec<MessageId>,
},
ChatAction {
chat_id: ChatId,
user_id: UserId,
action: String,
},
MessageInteractionInfo {
chat_id: ChatId,
message_id: MessageId,
reactions: Vec<ReactionInfo>,
},
ConnectionState {
state: NetworkState,
},
ChatReadOutbox {
chat_id: ChatId,
last_read_outbox_message_id: MessageId,
},
ChatDraftMessage {
chat_id: ChatId,
draft_text: Option<String>,
},
}
/// Simplified mock TDLib client for tests.
#[allow(dead_code)]
pub struct FakeTdClient {
pub chats: Arc<Mutex<Vec<ChatInfo>>>,
pub messages: Arc<Mutex<HashMap<i64, Vec<MessageInfo>>>>,
pub folders: Arc<Mutex<Vec<FolderInfo>>>,
pub user_names: Arc<Mutex<HashMap<i64, String>>>,
pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>,
pub drafts: Arc<Mutex<HashMap<i64, String>>>,
pub available_reactions: Arc<Mutex<Vec<String>>>,
pub network_state: Arc<Mutex<NetworkState>>,
pub typing_chat_id: Arc<Mutex<Option<i64>>>,
pub current_chat_id: Arc<Mutex<Option<i64>>>,
pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>,
pub auth_state: Arc<Mutex<AuthState>>,
pub sent_messages: Arc<Mutex<Vec<SentMessage>>>,
pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>,
pub deleted_messages: Arc<Mutex<Vec<DeletedMessages>>>,
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
pub viewed_messages: Arc<Mutex<ViewedMessages>>,
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>,
pub pending_view_messages: Arc<Mutex<PendingViewMessages>>,
pub incoming_message_events: Arc<Mutex<Vec<IncomingMessageEvent>>>,
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
pub simulate_delays: bool,
pub fail_next_operation: Arc<Mutex<bool>>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SentMessage {
pub chat_id: i64,
pub text: String,
pub reply_to: Option<MessageId>,
pub reply_info: Option<ReplyInfo>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EditedMessage {
pub chat_id: i64,
pub message_id: MessageId,
pub new_text: String,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DeletedMessages {
pub chat_id: i64,
pub message_ids: Vec<MessageId>,
pub revoke: bool,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ForwardedMessages {
pub from_chat_id: i64,
pub to_chat_id: i64,
pub message_ids: Vec<MessageId>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SearchQuery {
pub chat_id: i64,
pub query: String,
pub results_count: usize,
}
impl Default for FakeTdClient {
fn default() -> Self {
Self::new()
}
}
impl Clone for FakeTdClient {
fn clone(&self) -> Self {
Self {
chats: Arc::clone(&self.chats),
messages: Arc::clone(&self.messages),
folders: Arc::clone(&self.folders),
user_names: Arc::clone(&self.user_names),
profiles: Arc::clone(&self.profiles),
drafts: Arc::clone(&self.drafts),
available_reactions: Arc::clone(&self.available_reactions),
network_state: Arc::clone(&self.network_state),
typing_chat_id: Arc::clone(&self.typing_chat_id),
current_chat_id: Arc::clone(&self.current_chat_id),
current_pinned_message: Arc::clone(&self.current_pinned_message),
auth_state: Arc::clone(&self.auth_state),
sent_messages: Arc::clone(&self.sent_messages),
edited_messages: Arc::clone(&self.edited_messages),
deleted_messages: Arc::clone(&self.deleted_messages),
forwarded_messages: Arc::clone(&self.forwarded_messages),
searched_queries: Arc::clone(&self.searched_queries),
viewed_messages: Arc::clone(&self.viewed_messages),
chat_actions: Arc::clone(&self.chat_actions),
pending_view_messages: Arc::clone(&self.pending_view_messages),
incoming_message_events: Arc::clone(&self.incoming_message_events),
downloaded_files: Arc::clone(&self.downloaded_files),
update_tx: Arc::clone(&self.update_tx),
simulate_delays: self.simulate_delays,
fail_next_operation: Arc::clone(&self.fail_next_operation),
}
}
}
#[allow(dead_code)]
impl FakeTdClient {
pub fn new() -> Self {
Self {
chats: Arc::new(Mutex::new(vec![])),
messages: Arc::new(Mutex::new(HashMap::new())),
folders: Arc::new(Mutex::new(vec![FolderInfo { id: 0, name: "All".to_string() }])),
user_names: Arc::new(Mutex::new(HashMap::new())),
profiles: Arc::new(Mutex::new(HashMap::new())),
drafts: Arc::new(Mutex::new(HashMap::new())),
available_reactions: Arc::new(Mutex::new(vec![
"👍".to_string(),
"❤️".to_string(),
"😂".to_string(),
"😮".to_string(),
"😢".to_string(),
"🙏".to_string(),
"👏".to_string(),
"🔥".to_string(),
])),
network_state: Arc::new(Mutex::new(NetworkState::Ready)),
typing_chat_id: Arc::new(Mutex::new(None)),
current_chat_id: Arc::new(Mutex::new(None)),
current_pinned_message: Arc::new(Mutex::new(None)),
auth_state: Arc::new(Mutex::new(AuthState::Ready)),
sent_messages: Arc::new(Mutex::new(vec![])),
edited_messages: Arc::new(Mutex::new(vec![])),
deleted_messages: Arc::new(Mutex::new(vec![])),
forwarded_messages: Arc::new(Mutex::new(vec![])),
searched_queries: Arc::new(Mutex::new(vec![])),
viewed_messages: Arc::new(Mutex::new(vec![])),
chat_actions: Arc::new(Mutex::new(vec![])),
pending_view_messages: Arc::new(Mutex::new(vec![])),
incoming_message_events: Arc::new(Mutex::new(vec![])),
downloaded_files: Arc::new(Mutex::new(HashMap::new())),
update_tx: Arc::new(Mutex::new(None)),
simulate_delays: false,
fail_next_operation: Arc::new(Mutex::new(false)),
}
}
}

View File

@@ -0,0 +1,368 @@
//! Test implementation of the TDLib client traits for FakeTdClient.
use super::fake_tdclient::FakeTdClient;
use crate::tdlib::{
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
MessageClient, ReactionClient, UpdateClient, UserClient,
};
use crate::tdlib::{
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
UserOnlineStatus,
};
use crate::types::{ChatId, MessageId, UserId};
use async_trait::async_trait;
use std::borrow::Cow;
use std::path::PathBuf;
use tdlib_rs::enums::{ChatAction, Update};
#[async_trait]
impl AuthClient for FakeTdClient {
async fn send_phone_number(&self, _phone: String) -> Result<(), String> {
Ok(())
}
async fn send_code(&self, _code: String) -> Result<(), String> {
Ok(())
}
async fn send_password(&self, _password: String) -> Result<(), String> {
Ok(())
}
}
#[async_trait]
impl ChatClient for FakeTdClient {
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
let _ = FakeTdClient::load_chats(self, limit as usize).await?;
Ok(())
}
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
FakeTdClient::load_folder_chats(self, folder_id, limit as usize).await
}
async fn leave_chat(&self, _chat_id: ChatId) -> Result<(), String> {
Ok(())
}
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
FakeTdClient::get_profile_info(self, chat_id).await
}
fn chats(&self) -> &[ChatInfo] {
&[]
}
fn folders(&self) -> &[FolderInfo] {
&[]
}
fn main_chat_list_position(&self) -> i32 {
0
}
fn set_main_chat_list_position(&mut self, _position: i32) {}
fn update_chats<F>(&mut self, updater: F)
where
F: FnOnce(&mut Vec<ChatInfo>),
{
updater(&mut self.chats.lock().unwrap());
}
fn update_folders<F>(&mut self, updater: F)
where
F: FnOnce(&mut Vec<FolderInfo>),
{
updater(&mut self.folders.lock().unwrap());
}
}
#[async_trait]
impl ChatActionClient for FakeTdClient {
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
FakeTdClient::send_chat_action(self, chat_id, format!("{:?}", action)).await;
}
fn clear_stale_typing_status(&mut self) -> bool {
false
}
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
None
}
fn set_typing_status(&mut self, _status: Option<(UserId, String, std::time::Instant)>) {}
}
#[async_trait]
impl MessageClient for FakeTdClient {
async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
FakeTdClient::get_chat_history(self, chat_id, limit).await
}
async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
FakeTdClient::load_older_messages(self, chat_id, from_message_id).await
}
async fn get_pinned_messages(&mut self, _chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
Ok(self
.current_pinned_message
.lock()
.unwrap()
.clone()
.into_iter()
.collect())
}
async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {}
async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
FakeTdClient::search_messages(self, chat_id, query).await
}
async fn send_message(
&mut self,
chat_id: ChatId,
text: String,
reply_to_message_id: Option<MessageId>,
reply_info: Option<ReplyInfo>,
) -> Result<MessageInfo, String> {
FakeTdClient::send_message(self, chat_id, text, reply_to_message_id, reply_info).await
}
async fn edit_message(
&mut self,
chat_id: ChatId,
message_id: MessageId,
new_text: String,
) -> Result<MessageInfo, String> {
FakeTdClient::edit_message(self, chat_id, message_id, new_text).await
}
async fn delete_messages(
&mut self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String> {
FakeTdClient::delete_messages(self, chat_id, message_ids, revoke).await
}
async fn forward_messages(
&mut self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> Result<(), String> {
FakeTdClient::forward_messages(self, from_chat_id, to_chat_id, message_ids).await
}
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
FakeTdClient::set_draft_message(self, chat_id, text).await
}
fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> {
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
Cow::Owned(self.get_messages(chat_id))
} else {
Cow::Owned(Vec::new())
}
}
fn current_chat_id(&self) -> Option<ChatId> {
self.get_current_chat_id().map(ChatId::new)
}
fn current_pinned_message(&self) -> Option<MessageInfo> {
self.current_pinned_message.lock().unwrap().clone()
}
fn push_message(&mut self, msg: MessageInfo) {
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
self.messages
.lock()
.unwrap()
.entry(chat_id)
.or_default()
.push(msg);
}
}
fn clear_current_chat_messages(&mut self) {
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
self.messages.lock().unwrap().remove(&chat_id);
}
}
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
self.messages.lock().unwrap().insert(chat_id, messages);
}
}
fn update_current_chat_messages<F>(&mut self, updater: F)
where
F: FnOnce(&mut Vec<MessageInfo>),
{
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
let mut all_messages = self.messages.lock().unwrap();
updater(all_messages.entry(chat_id).or_default());
}
}
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
*self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64());
}
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
*self.current_pinned_message.lock().unwrap() = msg;
}
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
&[]
}
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
self.pending_view_messages
.lock()
.unwrap()
.push((chat_id, message_ids));
}
async fn fetch_missing_reply_info(&mut self) {}
async fn process_pending_view_messages(&mut self) {
let mut pending = self.pending_view_messages.lock().unwrap();
for (chat_id, message_ids) in pending.drain(..) {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
self.viewed_messages
.lock()
.unwrap()
.push((chat_id.as_i64(), ids));
}
}
}
#[async_trait]
impl UserClient for FakeTdClient {
fn get_user_status_by_chat_id(&self, _chat_id: ChatId) -> Option<&UserOnlineStatus> {
None
}
fn pending_user_ids(&self) -> &[UserId] {
&[]
}
fn user_cache(&self) -> &UserCache {
use std::sync::OnceLock;
static EMPTY_CACHE: OnceLock<UserCache> = OnceLock::new();
EMPTY_CACHE.get_or_init(|| UserCache::new(0))
}
fn update_user_cache<F>(&mut self, _updater: F)
where
F: FnOnce(&mut UserCache),
{
}
async fn process_pending_user_ids(&mut self) {}
}
#[async_trait]
impl ReactionClient for FakeTdClient {
async fn get_message_available_reactions(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> Result<Vec<String>, String> {
FakeTdClient::get_message_available_reactions(self, chat_id, message_id).await
}
async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
reaction: String,
) -> Result<(), String> {
FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await
}
}
#[async_trait]
impl FileClient for FakeTdClient {
async fn download_file(&self, file_id: i32) -> Result<String, String> {
FakeTdClient::download_file(self, file_id).await
}
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
FakeTdClient::download_file(self, file_id).await
}
}
#[async_trait]
impl ClientState for FakeTdClient {
fn client_id(&self) -> i32 {
0
}
async fn get_me(&self) -> Result<i64, String> {
Ok(12345)
}
fn auth_state(&self) -> &AuthState {
use std::sync::OnceLock;
static AUTH_STATE_READY: AuthState = AuthState::Ready;
static AUTH_STATE_WAIT_PHONE: OnceLock<AuthState> = OnceLock::new();
static AUTH_STATE_WAIT_CODE: OnceLock<AuthState> = OnceLock::new();
static AUTH_STATE_WAIT_PASSWORD: OnceLock<AuthState> = OnceLock::new();
let current = self.auth_state.lock().unwrap();
match *current {
AuthState::Ready => &AUTH_STATE_READY,
AuthState::WaitPhoneNumber => {
AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber)
}
AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode),
AuthState::WaitPassword => {
AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword)
}
_ => &AUTH_STATE_READY,
}
}
fn network_state(&self) -> crate::tdlib::types::NetworkState {
FakeTdClient::get_network_state(self)
}
}
#[async_trait]
impl AccountClient for FakeTdClient {
async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> {
Ok(())
}
}
impl UpdateClient for FakeTdClient {
fn handle_update(&mut self, _update: Update) {}
fn drain_incoming_message_events(&mut self) -> Vec<crate::tdlib::IncomingMessageEvent> {
self.incoming_message_events
.lock()
.unwrap()
.drain(..)
.collect()
}
}

View File

@@ -0,0 +1,7 @@
//! Core test support for deterministic TDLib fixtures.
pub mod fake_tdclient;
mod fake_tdclient_impl;
pub mod test_data;
pub use fake_tdclient::FakeTdClient;

View File

@@ -0,0 +1,252 @@
// Test data builders and fixtures
use crate::tdlib::types::{ForwardInfo, ReactionInfo};
use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use crate::types::{ChatId, MessageId};
/// Builder для создания тестового чата
#[allow(dead_code)]
pub struct TestChatBuilder {
id: i64,
title: String,
username: Option<String>,
last_message: String,
last_message_date: i32,
unread_count: i32,
unread_mention_count: i32,
is_pinned: bool,
order: i64,
last_read_outbox_message_id: i64,
folder_ids: Vec<i32>,
is_muted: bool,
draft_text: Option<String>,
}
#[allow(dead_code)]
impl TestChatBuilder {
pub fn new(title: &str, id: i64) -> Self {
Self {
id,
title: title.to_string(),
username: None,
last_message: "".to_string(),
last_message_date: 1640000000,
unread_count: 0,
unread_mention_count: 0,
is_pinned: false,
order: id,
last_read_outbox_message_id: 0,
folder_ids: vec![0],
is_muted: false,
draft_text: None,
}
}
pub fn username(mut self, username: &str) -> Self {
self.username = Some(username.to_string());
self
}
pub fn last_message(mut self, text: &str) -> Self {
self.last_message = text.to_string();
self
}
pub fn unread_count(mut self, count: i32) -> Self {
self.unread_count = count;
self
}
pub fn unread_mentions(mut self, count: i32) -> Self {
self.unread_mention_count = count;
self
}
pub fn pinned(mut self) -> Self {
self.is_pinned = true;
self
}
pub fn muted(mut self) -> Self {
self.is_muted = true;
self
}
pub fn draft(mut self, text: &str) -> Self {
self.draft_text = Some(text.to_string());
self
}
pub fn folder(mut self, folder_id: i32) -> Self {
self.folder_ids = vec![folder_id];
self
}
pub fn build(self) -> ChatInfo {
ChatInfo {
id: ChatId::new(self.id),
title: self.title,
username: self.username,
last_message: self.last_message,
last_message_date: self.last_message_date,
unread_count: self.unread_count,
unread_mention_count: self.unread_mention_count,
is_pinned: self.is_pinned,
order: self.order,
last_read_outbox_message_id: MessageId::new(self.last_read_outbox_message_id),
folder_ids: self.folder_ids,
is_muted: self.is_muted,
draft_text: self.draft_text,
}
}
}
/// Builder для создания тестового сообщения
#[allow(dead_code)]
pub struct TestMessageBuilder {
id: i64,
sender_name: String,
is_outgoing: bool,
content: String,
entities: Vec<tdlib_rs::types::TextEntity>,
date: i32,
edit_date: i32,
is_read: bool,
can_be_edited: bool,
can_be_deleted_only_for_self: bool,
can_be_deleted_for_all_users: bool,
reply_to: Option<ReplyInfo>,
forward_from: Option<ForwardInfo>,
reactions: Vec<ReactionInfo>,
media_album_id: i64,
}
#[allow(dead_code)]
impl TestMessageBuilder {
pub fn new(content: &str, id: i64) -> Self {
Self {
id,
sender_name: "User".to_string(),
is_outgoing: false,
content: content.to_string(),
entities: vec![],
date: 1640000000,
edit_date: 0,
is_read: true,
can_be_edited: false,
can_be_deleted_only_for_self: true,
can_be_deleted_for_all_users: false,
reply_to: None,
forward_from: None,
reactions: vec![],
media_album_id: 0,
}
}
pub fn outgoing(mut self) -> Self {
self.is_outgoing = true;
self.sender_name = "You".to_string();
self.can_be_edited = true;
self.can_be_deleted_for_all_users = true;
self
}
pub fn sender(mut self, name: &str) -> Self {
self.sender_name = name.to_string();
self
}
pub fn date(mut self, timestamp: i32) -> Self {
self.date = timestamp;
self
}
pub fn edited(mut self) -> Self {
self.edit_date = self.date + 60;
self
}
pub fn unread(mut self) -> Self {
self.is_read = false;
self
}
pub fn reply_to(mut self, message_id: i64, sender: &str, text: &str) -> Self {
self.reply_to = Some(ReplyInfo {
message_id: MessageId::new(message_id),
sender_name: sender.to_string(),
text: text.to_string(),
});
self
}
pub fn forwarded_from(mut self, sender: &str) -> Self {
self.forward_from = Some(ForwardInfo { sender_name: sender.to_string() });
self
}
pub fn reaction(mut self, emoji: &str, count: i32, chosen: bool) -> Self {
self.reactions
.push(ReactionInfo { emoji: emoji.to_string(), count, is_chosen: chosen });
self
}
pub fn media_album_id(mut self, id: i64) -> Self {
self.media_album_id = id;
self
}
pub fn build(self) -> MessageInfo {
let mut msg = MessageInfo::new(
MessageId::new(self.id),
self.sender_name,
self.is_outgoing,
self.content,
self.entities,
self.date,
self.edit_date,
self.is_read,
self.can_be_edited,
self.can_be_deleted_only_for_self,
self.can_be_deleted_for_all_users,
self.reply_to,
self.forward_from,
self.reactions,
);
msg.metadata.media_album_id = self.media_album_id;
msg
}
}
/// Хелперы для быстрого создания тестовых данных
pub fn create_test_chat(title: &str, id: i64) -> ChatInfo {
TestChatBuilder::new(title, id).build()
}
#[allow(dead_code)]
pub fn create_test_message(content: &str, id: i64) -> MessageInfo {
TestMessageBuilder::new(content, id).build()
}
#[allow(dead_code)]
pub fn create_test_user(name: &str, id: i64) -> (i64, String) {
(id, name.to_string())
}
/// Хелпер для создания профиля
#[allow(dead_code)]
pub fn create_test_profile(title: &str, chat_id: i64) -> ProfileInfo {
ProfileInfo {
chat_id: ChatId::new(chat_id),
title: title.to_string(),
username: None,
bio: None,
phone_number: None,
chat_type: "Личный чат".to_string(),
member_count: None,
description: None,
invite_link: None,
is_group: false,
online_status: None,
}
}

View File

@@ -0,0 +1,9 @@
use chrono::{DateTime, Local, NaiveDate, Utc};
pub fn get_day(timestamp: i32) -> i64 {
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date");
let msg_day = DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&Local).date_naive())
.unwrap_or(epoch);
msg_day.signed_duration_since(epoch).num_days()
}

View File

@@ -0,0 +1,27 @@
[package]
name = "tele-ios-ffi"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
description = "UniFFI bridge for the iOS Telegram client"
license = "MIT"
repository = "https://github.com/your-username/tele-tui"
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[features]
default = ["core-session-download"]
core-session = ["dep:tele-core"]
core-session-download = ["core-session", "tele-core/tdlib-download"]
core-session-local-tdlib = ["core-session", "tele-core/tdlib-local"]
standalone-fake = []
[dependencies]
tele-core = { path = "../tele-core", default-features = false, features = ["test-support"], optional = true }
tokio = { version = "1", features = ["rt-multi-thread"] }
thiserror = "1.0"
uniffi = { version = "0.31.1", features = ["tokio"] }
[dev-dependencies]
tele-core = { path = "../tele-core", default-features = false, features = ["test-support", "tdlib-download"] }

View File

@@ -0,0 +1,46 @@
# tele-ios-ffi
UniFFI bridge for the future native iOS app.
Current scope:
- Exposes a fake-backed `SessionHandle` for Swift integration tests and app shell work.
- Mirrors the `tele-core::session` DTO/event model with UniFFI-compatible records and enums.
- Supports a fake-only build for UI work and a real TDLib build path using local iOS TDLib artifacts.
Generate Swift bindings and headers:
```bash
scripts/generate-ios-ffi-bindings.sh
```
The script builds `target/release/libtele_ios_ffi.a` and writes Swift sources,
headers, a Swift typecheck-friendly `tele_ios_ffiFFI` module map, and an
XCFramework-compatible module map under `build/ios-ffi/`.
Build the fake-only iOS simulator XCFramework without linking TDLib:
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-fake-ffi-xcframework.sh
```
Run an executable Swift smoke test against matching fake-only UniFFI bindings:
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/smoke-ios-ffi-swift.sh
```
Typecheck the Swift app bridge against generated UniFFI bindings:
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/typecheck-ios-uniffi-app-bridge.sh
```
Current linking status:
- Xcode is installed at `/Applications/Xcode.app`, and `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -version` reports Xcode 26.5.
- The iOS 26.5 simulator runtime is installed and `scripts/check-ios-prereqs.sh` passes with available iPhone/iPad simulators.
- The current app shell uses the fake Swift bridge.
- `tdlib-rs` does not publish iOS `download-tdlib` archives, so real iOS linking uses `tele-core/tdlib-local` and `LOCAL_TDLIB_PATH`.
- Local TDLib linking is validated for `aarch64-apple-ios-sim` via `scripts/check-ios-tdlib-linking.sh` and for `aarch64-apple-ios` via `IOS_RUST_TARGET=aarch64-apple-ios scripts/build-ios-ffi-with-local-tdlib.sh`.
- `scripts/build-ios-real-ffi-xcframework.sh` packages local simulator Rust slices plus local `libtdjson` into app-local XCFrameworks, generates Swift bindings, and enables Xcode builds with `TELE_IOS_USE_LOCAL_FFI=1`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
[package]
name = "tele-tui"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
description = "Terminal UI for Telegram with Vim-style navigation"
license = "MIT"
repository = "https://github.com/your-username/tele-tui"
keywords = ["telegram", "tui", "terminal", "cli"]
categories = ["command-line-utilities"]
default-run = "tele-tui"
[features]
default = ["clipboard", "url-open", "notifications", "images"]
clipboard = ["dep:arboard"]
url-open = ["dep:open"]
notifications = ["dep:notify-rust"]
images = ["dep:ratatui-image", "dep:image", "tele-core/images"]
test-support = ["tele-core/test-support"]
[dependencies]
tele-core = { path = "../tele-core", default-features = false, features = ["tdlib-download"] }
ratatui = "0.29"
crossterm = "0.28"
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
chrono = "0.4"
open = { version = "5.0", optional = true }
arboard = { version = "3.4", optional = true }
notify-rust = { version = "4.11", optional = true }
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
image = { version = "0.25", optional = true }
toml = "0.8"
dirs = "5.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22.1"
fs2 = "0.4"
[dev-dependencies]
insta = "1.34"
tokio-test = "0.4"
criterion = "0.5"
termwright = "0.2"
[[bin]]
name = "tele-tui-test-fixture"
path = "src/bin/tele-tui-test-fixture.rs"
required-features = ["test-support"]
[[bench]]
name = "group_messages"
harness = false
[[bench]]
name = "formatting"
harness = false
[[bench]]
name = "format_markdown"
harness = false

38
crates/tele-tui/build.rs Normal file
View File

@@ -0,0 +1,38 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
fn main() {
println!("cargo:rerun-if-changed=build.rs");
for lib_dir in tdlib_lib_dirs() {
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir.display());
}
}
fn tdlib_lib_dirs() -> Vec<PathBuf> {
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
let workspace_dir = manifest_dir
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or(manifest_dir);
let build_dir = workspace_dir.join("target").join(profile).join("build");
let Ok(entries) = fs::read_dir(build_dir) else {
return Vec::new();
};
entries
.flatten()
.map(|entry| entry.path().join("out").join("tdlib").join("lib"))
.filter(|path| has_tdjson(path))
.collect()
}
fn has_tdjson(path: &Path) -> bool {
path.join("libtdjson.1.8.29.dylib").exists()
|| path.join("libtdjson.dylib").exists()
|| path.join("libtdjson.so").exists()
}

View File

@@ -15,7 +15,9 @@ pub use methods::*;
pub use state::AppScreen;
use crate::accounts::AccountProfile;
use crate::notifications::NotificationManager;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
use crate::tdlib::{TdClientConfig, TdCredentials};
use crate::types::{ChatId, MessageId};
use ratatui::widgets::ListState;
use std::path::PathBuf;
@@ -102,6 +104,7 @@ pub struct App<T: TdClientTrait = TdClient> {
config: crate::config::Config,
pub screen: AppScreen,
pub td_client: T,
pub notification_manager: NotificationManager,
/// Состояние чата - type-safe state machine (новое!)
pub chat_state: ChatState,
/// Vim-like input mode: Normal (navigation) / Insert (text input)
@@ -205,11 +208,13 @@ impl<T: TdClientTrait> App<T> {
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
#[cfg(feature = "images")]
let modal_image_renderer = crate::media::image_renderer::ImageRenderer::new();
let notification_manager = NotificationManager::from_config(&config.notifications);
App {
config,
screen: AppScreen::Loading,
td_client,
notification_manager,
chat_state: ChatState::Normal,
input_mode: InputMode::Normal,
phone_input: String::new(),
@@ -613,8 +618,18 @@ impl App<TdClient> {
///
/// A new `App<TdClient>` instance ready to start authentication.
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
let mut td_client = TdClient::new(db_path);
td_client.configure_notifications(&config.notifications);
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
let api_id = std::env::var("API_ID")
.unwrap_or_else(|_| "0".to_string())
.parse()
.unwrap_or(0);
let api_hash = std::env::var("API_HASH").unwrap_or_default();
(api_id, api_hash)
});
let td_client = TdClient::new(TdClientConfig {
credentials: TdCredentials { api_id, api_hash },
db_path,
});
App::with_client(config, td_client)
}
}

View File

@@ -0,0 +1,182 @@
use std::io;
use std::time::Duration;
use crossterm::{
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tele_tui::{
app::{App, AppScreen},
input::handle_main_input,
test_support::{
app_builder::TestAppBuilder,
fake_tdclient::FakeTdClient,
test_data::{TestChatBuilder, TestMessageBuilder},
},
};
#[tokio::main]
async fn main() -> io::Result<()> {
let scenario = parse_scenario();
let mut app = build_app(&scenario);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_fixture(&mut terminal, &mut app).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste
)?;
terminal.show_cursor()?;
result
}
fn parse_scenario() -> String {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--scenario" {
return args.next().unwrap_or_else(|| "inbox".to_string());
}
}
"inbox".to_string()
}
async fn run_fixture(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App<FakeTdClient>,
) -> io::Result<()> {
loop {
if app.needs_redraw {
terminal.draw(|f| tele_tui::ui::render(f, app))?;
app.needs_redraw = false;
}
if event::poll(Duration::from_millis(16))? {
match event::read()? {
Event::Key(key) => {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
return Ok(());
}
if key.code == KeyCode::F(10) {
return Ok(());
}
handle_main_input(app, normalize_fixture_key(key)).await;
app.needs_redraw = true;
}
Event::Resize(_, _) => {
app.needs_redraw = true;
}
Event::Paste(text) => {
for ch in text.chars() {
handle_main_input(
app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
)
.await;
}
app.needs_redraw = true;
}
_ => {}
}
}
}
}
fn normalize_fixture_key(key: KeyEvent) -> KeyEvent {
match (key.code, key.modifiers) {
(KeyCode::Char('/'), KeyModifiers::NONE) => {
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)
}
(KeyCode::Char('j' | 'm'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
}
_ => key,
}
}
fn build_app(scenario: &str) -> App<FakeTdClient> {
match scenario {
"open-chat" => TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.selected_chat(102)
.with_messages(102, sample_messages())
.build(),
"compose-draft" => TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.selected_chat(102)
.message_input("hello from e2e")
.with_messages(102, sample_messages())
.build(),
"inbox" => TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.with_messages(101, mom_messages())
.with_messages(102, sample_messages())
.with_messages(103, boss_messages())
.build(),
other => {
eprintln!("unknown scenario: {other}");
std::process::exit(2);
}
}
}
fn sample_chats() -> Vec<tele_tui::tdlib::ChatInfo> {
vec![
TestChatBuilder::new("Mom", 101)
.last_message("Dinner at 7?")
.unread_count(2)
.build(),
TestChatBuilder::new("Work Group", 102)
.last_message("Standup notes are ready")
.unread_mentions(1)
.build(),
TestChatBuilder::new("Boss", 103)
.last_message("Please review the deck")
.build(),
]
}
fn sample_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![
TestMessageBuilder::new("Morning, team", 201)
.sender("Alice")
.build(),
TestMessageBuilder::new("Standup notes are ready", 202)
.sender("Bob")
.build(),
TestMessageBuilder::new("Thanks, I will review them after lunch", 203)
.outgoing()
.build(),
]
}
fn mom_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![TestMessageBuilder::new("Dinner at 7?", 301)
.sender("Mom")
.build()]
}
fn boss_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![TestMessageBuilder::new("Please review the deck", 401)
.sender("Boss")
.build()]
}

View File

@@ -5,15 +5,19 @@
// ============================================================================
/// Максимальное количество сообщений в одном чате (для оптимизации памяти)
#[allow(dead_code)]
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
/// Максимальный размер кэша пользователей (LRU)
#[allow(dead_code)]
pub const MAX_USER_CACHE_SIZE: usize = 500;
/// Максимальное количество чатов для загрузки
#[allow(dead_code)]
pub const MAX_CHATS: usize = 200;
/// Максимальное количество user_ids для хранения в чате
#[allow(dead_code)]
pub const MAX_CHAT_USER_IDS: usize = 500;
// ============================================================================
@@ -27,6 +31,7 @@ pub const POLL_TIMEOUT_MS: u64 = 16;
pub const SHUTDOWN_TIMEOUT_SECS: u64 = 2;
/// Количество пользователей для ленивой загрузки за один тик
#[allow(dead_code)]
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
// ============================================================================
@@ -34,6 +39,7 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
// ============================================================================
/// Лимит количества сообщений для загрузки через TDLib за раз
#[allow(dead_code)]
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
// ============================================================================

View File

@@ -50,7 +50,8 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
// Синхронизируем muted чаты после обновления
app.td_client.sync_notification_muted_chats();
app.notification_manager
.sync_muted_chats(app.td_client.chats());
app.status_message = None;
true
}

Some files were not shown because too many files have changed in this diff Show More