mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Update tgwatch
This commit is contained in:
parent
354e1c7a66
commit
79ee96306b
18 changed files with 649 additions and 195 deletions
|
|
@ -0,0 +1,64 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Rendered by `TgwatchApp` whenever there is no active client (fresh install,
|
||||
/// or just after the last account was removed). On appear it asks the manager
|
||||
/// to provision a Production account — a no-op if one already exists — which
|
||||
/// flips `activeClient` and routes the app into `ContentView` → QR login. It
|
||||
/// only shows persistent UI in the rare persist-failure case, where it surfaces
|
||||
/// the error and a retry. There is no "welcome"/onboarding step.
|
||||
struct AccountBootstrapView: View {
|
||||
@Environment(AccountManager.self) private var manager
|
||||
|
||||
var body: some View {
|
||||
AccountBootstrapContent(
|
||||
errorMessage: manager.lastError,
|
||||
onRetry: { manager.ensureAccountExists() }
|
||||
)
|
||||
.task { manager.ensureAccountExists() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure presentational body for `AccountBootstrapView`. Shows a loading state
|
||||
/// while provisioning succeeds (the common case — it flashes briefly, then the
|
||||
/// app swaps to `ContentView`), or an error + retry when account persistence
|
||||
/// failed.
|
||||
struct AccountBootstrapContent: View {
|
||||
let errorMessage: String?
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
if let errorMessage {
|
||||
VStack(spacing: 16) {
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier("accountBootstrapError")
|
||||
Text("Try again")
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.gray.opacity(0.25), in: Capsule())
|
||||
.contentShape(Capsule())
|
||||
.onTapGesture { onRetry() }
|
||||
.accessibilityIdentifier("accountBootstrapRetry")
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
LoadingView(label: "Preparing…")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Loading") {
|
||||
AccountBootstrapContent(errorMessage: nil, onRetry: {})
|
||||
.background(Color.black)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
#Preview("Error") {
|
||||
AccountBootstrapContent(errorMessage: "Couldn't save account list.", onRetry: {})
|
||||
.background(Color.black)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
#endif
|
||||
|
|
@ -72,6 +72,19 @@ final class AccountManager: TDClientLifecycleDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
/// Ensures at least one account exists by creating and starting a fresh
|
||||
/// **Production** account when the registry is empty and no client is
|
||||
/// active. Idempotent — the guard makes repeat calls safe — and
|
||||
/// synchronous like `bootstrap()` so callers/tests can read post-state
|
||||
/// without awaiting. Drives the no-welcome auto-QR flow: whenever
|
||||
/// `activeClient` is nil, `AccountBootstrapView` calls this to land the
|
||||
/// app on QR login. Persist-failure handling (revert + `lastError`) is
|
||||
/// inherited from `appendAndStart`.
|
||||
func ensureAccountExists() {
|
||||
guard accounts.isEmpty, activeClient == nil else { return }
|
||||
appendAndStart(account: makeNewAccount(useTestDc: false))
|
||||
}
|
||||
|
||||
/// Adds a new account, persists, makes it active, and brings up a
|
||||
/// fresh `TDClient` against its (empty) database directory. The
|
||||
/// previous active account (if any) is **closed**, not logged out —
|
||||
|
|
@ -194,7 +207,7 @@ final class AccountManager: TDClientLifecycleDelegate {
|
|||
/// `TDClient`. On persist failure, reverts in-memory state to match
|
||||
/// the (failed) disk write so the user doesn't see a phantom account
|
||||
/// that disappears on next launch. The user sees `lastError` in
|
||||
/// `AccountsListView` / `AccountsEmptyView` and can retry.
|
||||
/// `AccountsListView` / `AccountBootstrapView` and can retry.
|
||||
private func appendAndStart(account: Account) {
|
||||
let previousAccounts = accounts
|
||||
let previousActiveId = activeAccountId
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
/// First-run / fully-empty state: a single "Add account" button.
|
||||
struct AccountsEmptyView: View {
|
||||
@Environment(AccountManager.self) private var manager
|
||||
@State private var showDcSheet = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Welcome to tgwatch")
|
||||
.font(.headline)
|
||||
Text("Add a Telegram account to get started.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
if let err = manager.lastError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier("accountManagerError")
|
||||
}
|
||||
Text("Add account")
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.gray.opacity(0.25), in: Capsule())
|
||||
.contentShape(Capsule())
|
||||
.onTapGesture {
|
||||
Task { await manager.addAccount(useTestDc: false) }
|
||||
}
|
||||
.onLongPressGesture(minimumDuration: 0.5) { showDcSheet = true }
|
||||
.accessibilityIdentifier("addAccountButton")
|
||||
}
|
||||
.padding()
|
||||
.confirmationDialog(
|
||||
"Choose server",
|
||||
isPresented: $showDcSheet,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Production") {
|
||||
Task { await manager.addAccount(useTestDc: false) }
|
||||
}
|
||||
Button("Test (developers)") {
|
||||
Task { await manager.addAccount(useTestDc: true) }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 72 KiB |
|
|
@ -6,61 +6,106 @@ struct PasswordEntryView: View {
|
|||
@Environment(TDClient.self) private var client
|
||||
@State private var password = ""
|
||||
@State private var submitting = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
/// Drives the error `.alert`: presented whenever `errorMessage` is set,
|
||||
/// and clears it on dismiss. Mirrors the optional-state→Bool binding
|
||||
/// pattern used by `AccountsListView`.
|
||||
private var errorAlertBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("2-step verification")
|
||||
.font(.headline)
|
||||
if !info.hint.isEmpty {
|
||||
Text("Hint: \(info.hint)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Enter your cloud password")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.password)
|
||||
.accessibilityIdentifier("passwordField")
|
||||
Button(action: submit) {
|
||||
if submitting {
|
||||
ProgressView()
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !info.hint.isEmpty {
|
||||
Text("Hint: \(info.hint)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Continue")
|
||||
Text("Enter your cloud password")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.password)
|
||||
.accessibilityIdentifier("passwordField")
|
||||
Button(action: submit) {
|
||||
Group {
|
||||
if submitting {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Continue")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.padding(.vertical, 6)
|
||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("passwordContinue")
|
||||
}
|
||||
.disabled(password.isEmpty || submitting)
|
||||
.accessibilityIdentifier("passwordContinue")
|
||||
if let err = client.lastError {
|
||||
Text(err)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityIdentifier("passwordError")
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Password")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// Force the nav bar to materialize so the title renders under the
|
||||
// clock — watchOS-26 skips chrome on a NavigationStack root view
|
||||
// otherwise (same reason as ChatListView).
|
||||
.toolbar(.visible, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
returnToQr()
|
||||
} label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
}
|
||||
.disabled(submitting)
|
||||
.accessibilityIdentifier("passwordBackToQr")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
.alert(errorMessage ?? "", isPresented: errorAlertBinding) {
|
||||
Button("OK", role: .cancel) { errorMessage = nil }
|
||||
}
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
if password.isEmpty,
|
||||
let preset = ProcessInfo.processInfo.environment["TGWATCH_PASSWORD"],
|
||||
!preset.isEmpty {
|
||||
password = preset
|
||||
submit()
|
||||
}
|
||||
if password.isEmpty,
|
||||
let preset = ProcessInfo.processInfo.environment["TGWATCH_PASSWORD"],
|
||||
!preset.isEmpty {
|
||||
password = preset
|
||||
submit()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.accountSwitcherSheet(presentation: .sheet, logoutAffordance: .suppressed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns to QR code entry. Blocked while a password check is in flight
|
||||
/// (TDLib rejects `requestQrCodeAuthentication` with a pending auth query).
|
||||
/// On success TDLib transitions to `waitOtherDeviceConfirmation` and this
|
||||
/// view is torn down; on failure the message lands in the existing alert.
|
||||
private func returnToQr() {
|
||||
guard !submitting else { return }
|
||||
Task {
|
||||
errorMessage = await client.returnToQrCode()
|
||||
}
|
||||
.accountSwitcherSheet(presentation: .sheet, logoutAffordance: .suppressed)
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
// Button is always painted enabled; ignore taps when there's nothing
|
||||
// to submit or a submission is already in flight.
|
||||
guard !password.isEmpty, !submitting else { return }
|
||||
let passwordCopy = password
|
||||
submitting = true
|
||||
Task {
|
||||
await client.submitPassword(passwordCopy)
|
||||
let error = await client.submitPassword(passwordCopy)
|
||||
submitting = false
|
||||
errorMessage = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ struct ChatListView: View {
|
|||
let store: ChatListStore
|
||||
|
||||
@State private var dismissedLastError: String? = nil
|
||||
@State private var opener = ChatOpener()
|
||||
/// Bound to the outer chat-list `List`. Re-asserted after every pill tap so the
|
||||
/// digital crown stays glued to the chat list, never the pill bar's horizontal
|
||||
/// ScrollView (where rotation has no useful effect). watchOS otherwise routes
|
||||
|
|
@ -24,8 +25,8 @@ struct ChatListView: View {
|
|||
}
|
||||
content(proxy: proxy)
|
||||
}
|
||||
.navigationDestination(for: ChatRow.self) { row in
|
||||
MessageListView(row: row)
|
||||
.navigationDestination(item: $opener.target) { target in
|
||||
MessageListView(row: target.row, store: target.store)
|
||||
}
|
||||
.navigationTitle("Chats")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
@ -78,13 +79,16 @@ struct ChatListView: View {
|
|||
emptyStateRow(loadState: folderLoadState)
|
||||
} else {
|
||||
ForEach(Array(store.chats.enumerated()), id: \.element.id) { idx, row in
|
||||
NavigationLink(value: row) {
|
||||
Button {
|
||||
opener.open(row, makeStore: makeHistoryStore)
|
||||
} label: {
|
||||
ChatRowView(
|
||||
row: row,
|
||||
onRequestDownload: { store.requestFileDownload(fileId: $0) },
|
||||
onCancelDownload: { store.cancelFileDownload(fileId: $0) }
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear { store.ensureChatsLoaded(near: idx) }
|
||||
}
|
||||
}
|
||||
|
|
@ -101,6 +105,23 @@ struct ChatListView: View {
|
|||
.padding(.top, store.pills.count > 1 ? -20 : 0)
|
||||
}
|
||||
|
||||
private func makeHistoryStore(for row: ChatRow) -> ChatHistoryStore? {
|
||||
guard let loader = client.makeChatHistoryLoader() else { return nil }
|
||||
return ChatHistoryStore(
|
||||
chatId: row.id,
|
||||
chatType: row.chatType,
|
||||
lastReadInboxMessageId: row.lastReadInboxMessageId,
|
||||
lastReadOutboxMessageId: row.lastReadOutboxMessageId,
|
||||
unreadCount: row.unreadCount,
|
||||
lastMessageId: row.lastMessageId,
|
||||
loader: loader,
|
||||
selfUserId: client.me?.id,
|
||||
userNames: client.userNames,
|
||||
draftText: row.draftText,
|
||||
coalesceUpdates: true
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func emptyStateRow(loadState: LoadState) -> some View {
|
||||
Group {
|
||||
|
|
|
|||
64
Telegram/WatchApp/tgwatch Watch App/Chats/ChatOpener.swift
Normal file
64
Telegram/WatchApp/tgwatch Watch App/Chats/ChatOpener.swift
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Carrier for a pushed chat: the row plus its pre-built (warmed or warming)
|
||||
/// `ChatHistoryStore`. Navigation identity is the chat id only — `ChatHistoryStore`
|
||||
/// is a reference type and intentionally excluded from equality/hashing.
|
||||
struct OpenChatTarget: Identifiable, Hashable {
|
||||
let row: ChatRow
|
||||
let store: ChatHistoryStore
|
||||
var id: Int64 { row.id }
|
||||
|
||||
static func == (lhs: OpenChatTarget, rhs: OpenChatTarget) -> Bool { lhs.row.id == rhs.row.id }
|
||||
func hash(into hasher: inout Hasher) { hasher.combine(row.id) }
|
||||
}
|
||||
|
||||
/// Coordinates "warm the store, then push" so a cached chat opens instantly and
|
||||
/// correctly positioned, while an uncached chat still pushes promptly (with the
|
||||
/// in-view spinner) instead of hanging the tap. Owned + injected by `ChatListView`.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ChatOpener {
|
||||
/// Drives `.navigationDestination(item:)`. Non-nil → push. Cleared on pop.
|
||||
var target: OpenChatTarget?
|
||||
/// Chat id currently being warmed (available for a pressed/redacted row
|
||||
/// affordance during the wait; not wired by default — the wait is ~150ms).
|
||||
private(set) var pendingRowId: Int64?
|
||||
|
||||
/// Max time to wait for `warm()` before pushing anyway with the in-view spinner.
|
||||
/// Instance-mutable so tests can shorten it. ~150ms: long enough that a local
|
||||
/// cache load wins, short enough that an uncached tap still feels responsive.
|
||||
var openTimeoutNs: UInt64 = 150_000_000
|
||||
|
||||
/// `makeStore` returns the store to warm+push (or nil if dependencies are
|
||||
/// unavailable, e.g. no TDLib client). Kept as a closure so `ChatOpener` has no
|
||||
/// `TDClient` dependency and stays unit-testable.
|
||||
func open(_ row: ChatRow, makeStore: (ChatRow) -> ChatHistoryStore?) {
|
||||
guard target == nil, pendingRowId == nil, let store = makeStore(row) else { return }
|
||||
pendingRowId = row.id
|
||||
let timeoutNs = openTimeoutNs
|
||||
// Owned task: runs to completion regardless of the race below. Dropping a
|
||||
// Task handle does NOT cancel it, so the timeout-loser keeps loading.
|
||||
let warmTask = Task { await store.warm() }
|
||||
Task { [weak self] in
|
||||
await Self.waitForWarmOrTimeout(store: store, timeoutNs: timeoutNs)
|
||||
guard let self else { return }
|
||||
self.target = OpenChatTarget(row: row, store: store)
|
||||
self.pendingRowId = nil
|
||||
}
|
||||
_ = warmTask
|
||||
}
|
||||
|
||||
/// Returns when `store.loadState` leaves `.loadingFirstPage` (warm finished) OR
|
||||
/// the deadline elapses, whichever is first. Polls rather than `await`-ing the
|
||||
/// warm task's value — `Task.value` is not cancellation-aware, so awaiting it
|
||||
/// inside a race would block until warm finished even after the timeout fired.
|
||||
private static func waitForWarmOrTimeout(store: ChatHistoryStore, timeoutNs: UInt64) async {
|
||||
let stepNs: UInt64 = 16_000_000 // ~one frame
|
||||
var waitedNs: UInt64 = 0
|
||||
while store.loadState == .loadingFirstPage && waitedNs < timeoutNs {
|
||||
try? await Task.sleep(nanoseconds: stepNs)
|
||||
waitedNs += stepNs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,23 @@ func humanMessage(_ error: Swift.Error) -> String {
|
|||
return (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
|
||||
/// Maps a TDLib error from `checkAuthenticationPassword` to one of three
|
||||
/// user-facing messages for the 2-step verification screen. Unlike
|
||||
/// `humanMessage`, unknown codes collapse to a generic message rather than
|
||||
/// passing the raw code through.
|
||||
func passwordSubmitErrorMessage(_ error: Swift.Error) -> String {
|
||||
guard let tdErr = error as? TDLibKit.Error else {
|
||||
return "An error occurred"
|
||||
}
|
||||
if tdErr.message == "PASSWORD_HASH_INVALID" {
|
||||
return "Incorrect password"
|
||||
}
|
||||
if let flood = floodWaitMessage(tdErr.message) {
|
||||
return flood
|
||||
}
|
||||
return "An error occurred"
|
||||
}
|
||||
|
||||
private func humanMessageForTdLibCode(_ code: String) -> String {
|
||||
switch code {
|
||||
case "PHONE_NUMBER_INVALID":
|
||||
|
|
@ -21,13 +38,17 @@ private func humanMessageForTdLibCode(_ code: String) -> String {
|
|||
case "LASTNAME_INVALID":
|
||||
return "That last name doesn't look right."
|
||||
default:
|
||||
if code.hasPrefix("FLOOD_WAIT_") {
|
||||
let suffix = code.dropFirst("FLOOD_WAIT_".count)
|
||||
if let seconds = Int(suffix), seconds > 0 {
|
||||
return "Too many attempts. Wait \(seconds)s."
|
||||
}
|
||||
return "Too many attempts. Try again later."
|
||||
}
|
||||
return code
|
||||
return floodWaitMessage(code) ?? code
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a `FLOOD_WAIT_<seconds>` code into a wait message, or `nil` if the
|
||||
/// code is not a flood-wait code.
|
||||
private func floodWaitMessage(_ code: String) -> String? {
|
||||
guard code.hasPrefix("FLOOD_WAIT_") else { return nil }
|
||||
let suffix = code.dropFirst("FLOOD_WAIT_".count)
|
||||
if let seconds = Int(suffix), seconds > 0 {
|
||||
return "Too many attempts. Wait \(seconds)s."
|
||||
}
|
||||
return "Too many attempts. Try again later."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ final class ChatHistoryStore {
|
|||
private var files: [Int: File] = [:]
|
||||
private var trackedFileIds: Set<Int> = []
|
||||
private var openChatSent: Bool = false
|
||||
private var closePending: Bool = false
|
||||
private var isLoading: Bool = false
|
||||
|
||||
private static let halfLimit: Int = 15
|
||||
|
|
@ -124,6 +125,48 @@ final class ChatHistoryStore {
|
|||
}
|
||||
}
|
||||
|
||||
/// History-only initial load with NO side effects (no `openChat`). Safe to run
|
||||
/// before the user has committed to the chat (e.g. pre-warm on tap). Mirrors the
|
||||
/// load half of `start()`; `activate()` performs the `openChat` half separately.
|
||||
func warm() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
loadState = .loadingFirstPage
|
||||
lastSendError = nil
|
||||
unseenNewerCount = 0
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
try await loadInitialWindow()
|
||||
loadState = .loaded
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
logger.warning("warm failed chatId=\(self.chatId, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
loadState = .failed(humanMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifies TDLib the chat is being viewed (`openChat`), guarded to run once.
|
||||
/// Called from the view's `.task` once the user actually lands on the chat.
|
||||
/// An `openChat` failure is logged but does NOT blank already-loaded history —
|
||||
/// the worst case is delayed supergroup/channel live updates, not a dead screen.
|
||||
func activate() async {
|
||||
guard !openChatSent, !closePending else { return }
|
||||
do {
|
||||
try await loader.openChat(chatId: chatId)
|
||||
openChatSent = true
|
||||
// If stop() landed while openChat was in flight (the view popped during the
|
||||
// round-trip), honor the close now so the chat doesn't leak open on TDLib.
|
||||
if closePending {
|
||||
try? await loader.closeChat(chatId: chatId)
|
||||
openChatSent = false
|
||||
closePending = false
|
||||
}
|
||||
} catch {
|
||||
logger.warning("openChat chatId=\(self.chatId, privacy: .public) failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Anchored initial fill: when `unreadDividerAfterId != nil`, we call
|
||||
/// `getChatHistory(fromMessageId: divider, offset: -(halfLimit+1), limit: 2*halfLimit)`
|
||||
/// → up to (halfLimit+1) newer + divider + ~(halfLimit-1) older. Otherwise we
|
||||
|
|
@ -186,7 +229,13 @@ final class ChatHistoryStore {
|
|||
viewDrainTask?.cancel()
|
||||
viewDrainTask = nil
|
||||
pendingViewIds.removeAll()
|
||||
guard openChatSent else { return }
|
||||
guard openChatSent else {
|
||||
// activate() may have openChat in flight (the view popped before it
|
||||
// returned). Mark so activate() closes the chat once openChat completes —
|
||||
// otherwise it leaks open on TDLib.
|
||||
closePending = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await loader.closeChat(chatId: chatId)
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ struct MessageBubbleView: View {
|
|||
replyHeader: bubble.replyHeader,
|
||||
onVote: { onPollTap(bubble.messageId, poll) }
|
||||
)
|
||||
} else if bubble.isUnsupported {
|
||||
UnsupportedBubbleView(
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader
|
||||
)
|
||||
} else if bubble.replyHeader == nil, let emojiCount = emojiOnlyCount(bubble.body) {
|
||||
Text(bubble.body)
|
||||
.font(.system(size: jumboEmojiSize(for: emojiCount)))
|
||||
|
|
@ -249,4 +254,17 @@ private let previewSampleBubble = MessageBubble(
|
|||
)
|
||||
.bubblePreview()
|
||||
}
|
||||
|
||||
#Preview("Unsupported message — incoming") {
|
||||
MessageBubbleView(
|
||||
bubble: MessageBubble(
|
||||
messageId: 13, isOutgoing: false, senderName: "Alice", body: "",
|
||||
photo: nil, video: nil, videoNote: nil, voiceNote: nil, audio: nil, document: nil,
|
||||
sticker: nil, location: nil, poll: nil, sendingState: .sent, replyHeader: nil,
|
||||
isUnsupported: true
|
||||
),
|
||||
onPhotoTap: { _ in }, onVideoTap: { _ in }, onVideoNoteTap: { _ in }, onPollTap: { _, _ in }
|
||||
)
|
||||
.bubblePreview()
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -25,6 +25,26 @@ func messageBody(_ content: MessageContent) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// True for message content that has no dedicated bubble rendering and should show the
|
||||
/// "Unsupported message" placeholder. Uses positive classification so TDLib's closed
|
||||
/// `MessageContent` enum's `default` cleanly captures every unhandled type
|
||||
/// (animations/GIFs, games, invoices, calls, dice, stories, gifts, expired media, …).
|
||||
///
|
||||
/// Service-message content is filtered to `.service` rows upstream (see
|
||||
/// `serviceLineText`) and never reaches the bubble path, so it is irrelevant here.
|
||||
/// `messageContact` is treated as supported — it keeps its existing "Contact" text
|
||||
/// fallback in `messageBody`.
|
||||
func isUnsupportedContent(_ content: MessageContent) -> Bool {
|
||||
switch content {
|
||||
case .messageText, .messagePhoto, .messageVideo, .messageVideoNote,
|
||||
.messageVoiceNote, .messageAudio, .messageSticker, .messageDocument,
|
||||
.messageLocation, .messageVenue, .messageContact, .messagePoll:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Day separator label between messages from different days.
|
||||
/// `Today` / `Yesterday` for the boundary days, weekday name for the last 7 days,
|
||||
/// `MMM d` for older or future dates.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ struct MessageListView: View {
|
|||
@Environment(TDClient.self) private var client
|
||||
let row: ChatRow
|
||||
|
||||
@State private var store: ChatHistoryStore?
|
||||
@State private var store: ChatHistoryStore
|
||||
@State private var presentedPhoto: PhotoVisual?
|
||||
@State private var presentedVideo: VideoVisual?
|
||||
@State private var presentedVideoNote: VideoNoteVisual?
|
||||
|
|
@ -40,28 +40,23 @@ struct MessageListView: View {
|
|||
// next pagination is allowed.
|
||||
@State private var canPaginate: Bool = true
|
||||
|
||||
init(row: ChatRow) {
|
||||
init(row: ChatRow, store: ChatHistoryStore) {
|
||||
self.row = row
|
||||
self._store = State(initialValue: store)
|
||||
// Opens at tail → starts at bottom. Opens at divider → user is NOT at bottom.
|
||||
self._isAtBottom = State(initialValue: row.unreadCount == 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let store {
|
||||
content(store: store)
|
||||
} else {
|
||||
LoadingView(label: "Loading…")
|
||||
}
|
||||
}
|
||||
content(store: store)
|
||||
.navigationTitle(row.title)
|
||||
.accessibilityIdentifier("messageListView")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
AvatarView(
|
||||
avatar: row.avatar,
|
||||
onRequestDownload: { fileId in store?.requestFileDownload(fileId: fileId) },
|
||||
onCancelDownload: { fileId in store?.cancelFileDownload(fileId: fileId) },
|
||||
onRequestDownload: { fileId in store.requestFileDownload(fileId: fileId) },
|
||||
onCancelDownload: { fileId in store.cancelFileDownload(fileId: fileId) },
|
||||
size: 36
|
||||
)
|
||||
.glassEffect(in: Circle())
|
||||
|
|
@ -71,72 +66,57 @@ struct MessageListView: View {
|
|||
PhotoViewerView(photo: photo)
|
||||
}
|
||||
.sheet(item: $presentedVideo) { video in
|
||||
if let store {
|
||||
VideoPlayerView(video: video).environment(store)
|
||||
}
|
||||
VideoPlayerView(video: video).environment(store)
|
||||
}
|
||||
.sheet(item: $presentedVideoNote) { note in
|
||||
if let store {
|
||||
VideoNotePlayerView(note: note).environment(store)
|
||||
}
|
||||
VideoNotePlayerView(note: note).environment(store)
|
||||
}
|
||||
.sheet(item: $presentedPoll) { target in
|
||||
if let store {
|
||||
PollVoteView(
|
||||
initialPoll: target.poll,
|
||||
currentPoll: { store.poll(forMessageId: target.id) },
|
||||
onVote: { await store.setPollAnswer(messageId: target.id, optionIds: $0) }
|
||||
)
|
||||
}
|
||||
PollVoteView(
|
||||
initialPoll: target.poll,
|
||||
currentPoll: { store.poll(forMessageId: target.id) },
|
||||
onVote: { await store.setPollAnswer(messageId: target.id, optionIds: $0) }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showAttachment) {
|
||||
if let store {
|
||||
AttachmentSheet(
|
||||
stickerPickerStore: stickerPickerStore,
|
||||
onSendSticker: { await store.sendSticker($0) },
|
||||
onSendVoiceNote: { await store.sendVoiceNote($0) },
|
||||
onPrepareVoice: {
|
||||
store.voicePlayback.tearDown()
|
||||
store.audioPlayback.tearDown()
|
||||
},
|
||||
onSendLocation: { latitude, longitude in
|
||||
await store.sendLocation(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
)
|
||||
.environment(client)
|
||||
}
|
||||
AttachmentSheet(
|
||||
stickerPickerStore: stickerPickerStore,
|
||||
onSendSticker: { await store.sendSticker($0) },
|
||||
onSendVoiceNote: { await store.sendVoiceNote($0) },
|
||||
onPrepareVoice: {
|
||||
store.voicePlayback.tearDown()
|
||||
store.audioPlayback.tearDown()
|
||||
},
|
||||
onSendLocation: { latitude, longitude in
|
||||
await store.sendLocation(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
)
|
||||
.environment(client)
|
||||
// Inject the picker store at the sheet-content root (above
|
||||
// AttachmentSheet's NavigationStack), mirroring `client`. The
|
||||
// store must live above the stack so pushed destinations —
|
||||
// StickerSetDetailView and its StickerCellViews — inherit it;
|
||||
// injecting only inside StickerPickerView (below the stack)
|
||||
// left pushed set-detail views with no store and trapped on
|
||||
// the `@Environment(StickerPickerStore.self)` lookup.
|
||||
.environment(stickerPickerStore)
|
||||
}
|
||||
.task {
|
||||
guard store == nil, let loader = client.makeChatHistoryLoader() else { return }
|
||||
let s = ChatHistoryStore(
|
||||
chatId: row.id,
|
||||
chatType: row.chatType,
|
||||
lastReadInboxMessageId: row.lastReadInboxMessageId,
|
||||
lastReadOutboxMessageId: row.lastReadOutboxMessageId,
|
||||
unreadCount: row.unreadCount,
|
||||
lastMessageId: row.lastMessageId,
|
||||
loader: loader,
|
||||
selfUserId: client.me?.id,
|
||||
userNames: client.userNames,
|
||||
draftText: row.draftText,
|
||||
coalesceUpdates: true
|
||||
)
|
||||
self.store = s
|
||||
client.setActiveHistory(s)
|
||||
// Build the picker store HERE (in the chat .task), not lazily in the
|
||||
// attachment-tap closure. Creating an @State and flipping a sheet-present
|
||||
// flag in the same closure races: the .sheet evaluates `if let pickerStore`
|
||||
// against a snapshot where the store is still nil → empty body. Having it
|
||||
// non-nil before the tap (like `store` above) avoids that.
|
||||
client.setActiveHistory(store)
|
||||
// Defer openChat to here (the store was warmed without it). Independent
|
||||
// of warm: if the window is still loading, openChat proceeds anyway.
|
||||
await store.activate()
|
||||
// Build the picker store HERE (not lazily in the attachment-tap closure):
|
||||
// creating an @State and flipping a sheet-present flag in the same closure
|
||||
// races the .sheet's `if let pickerStore` against a nil snapshot.
|
||||
if stickerPickerStore == nil, let pl = client.makeStickerPickerLoader() {
|
||||
stickerPickerStore = StickerPickerStore(loader: pl)
|
||||
}
|
||||
await s.start()
|
||||
}
|
||||
.onDisappear {
|
||||
let s = self.store
|
||||
client.setActiveHistory(nil)
|
||||
Task { await s?.stop() }
|
||||
Task { await s.stop() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,26 +250,6 @@ struct MessageListView: View {
|
|||
.ignoresSafeArea()
|
||||
}
|
||||
.environment(store)
|
||||
.task {
|
||||
// Default branch only appears once `loadState == .loaded`
|
||||
// (the .loadingFirstPage case above keeps the spinner up).
|
||||
// .task fires once per view appearance; the guard makes the
|
||||
// scroll idempotent across re-renders. A short sleep lets
|
||||
// SwiftUI lay out the freshly-rendered VStack before scrollTo
|
||||
// resolves the target id — otherwise scrollTo can land before
|
||||
// the row exists in the rendered tree.
|
||||
//
|
||||
// .defaultScrollAnchor(.bottom) on the ScrollView above
|
||||
// positions the no-divider case at the chat tail BEFORE this
|
||||
// task fires (so the user never sees the top-flash). We only
|
||||
// need to override that for the unread-divider case.
|
||||
guard !didApplyInitialScroll else { return }
|
||||
didApplyInitialScroll = true
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
if let target = store.window.initialScrollTargetId {
|
||||
proxy.scrollTo(target, anchor: .top)
|
||||
}
|
||||
}
|
||||
.onScrollGeometryChange(for: ScrollSnapshot.self) { geometry in
|
||||
ScrollSnapshot(
|
||||
contentOffsetY: geometry.contentOffset.y,
|
||||
|
|
@ -317,6 +277,28 @@ struct MessageListView: View {
|
|||
// pagination so it only fires after the user actually moved.
|
||||
userHasScrolled = true
|
||||
}
|
||||
.task {
|
||||
// Initial positioning. This `default` branch only renders once the
|
||||
// store is `.loaded` (the `.loadingFirstPage` case keeps the spinner
|
||||
// up), so `store.rows` is fully populated — true for BOTH the fast
|
||||
// path (warmed before push) and the slow path (warm finished after
|
||||
// push, swapping this branch in). For the unread case, scroll to the
|
||||
// divider; the spinner cover (see `content`'s ZStack) hides the brief
|
||||
// bottom-paint→divider settle so there's no visible jump. We use
|
||||
// `proxy.scrollTo` rather than `scrollPosition(id:)` because the
|
||||
// content is a plain (non-lazy) `VStack` — `scrollPosition(id:)` only
|
||||
// positions reliably inside lazy stacks, and switching to `LazyVStack`
|
||||
// reintroduces the `defaultScrollAnchor` over-scroll gotcha.
|
||||
guard !didApplyInitialScroll else { return }
|
||||
if let target = store.window.initialScrollTargetId {
|
||||
// Let SwiftUI lay out the rows before resolving the target id.
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
proxy.scrollTo(target, anchor: .top)
|
||||
// One more runloop tick so the scroll offset lands before reveal.
|
||||
await Task.yield()
|
||||
}
|
||||
didApplyInitialScroll = true
|
||||
}
|
||||
.onChange(of: store.rows.first?.id) { oldId, newId in
|
||||
// Scroll preservation across `loadOlder`. When older content is
|
||||
// prepended, SwiftUI keeps the absolute scroll offset where it was
|
||||
|
|
@ -384,6 +366,17 @@ struct MessageListView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Cover the brief bottom-paint→divider settle (the `.task` above scrolls to
|
||||
// the unread divider ~50ms after this branch appears) so the user never sees
|
||||
// the jump. Only for the unread case — the tail case is already correctly
|
||||
// placed by `.defaultScrollAnchor(.bottom)`, so it reveals immediately.
|
||||
.overlay {
|
||||
if !didApplyInitialScroll, store.window.initialScrollTargetId != nil {
|
||||
LoadingView(label: "Loading messages…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ struct MessageBubble: Equatable, Hashable {
|
|||
var senderColorIndex: Int? = nil
|
||||
/// True for a sent outgoing message the recipient hasn't read yet (`id > lastReadOutboxMessageId`).
|
||||
var isUnreadOutgoing: Bool = false
|
||||
/// True for content with no dedicated bubble — renders the "Unsupported message"
|
||||
/// placeholder. Set from `isUnsupportedContent(msg.content)` in `messageRows`.
|
||||
var isUnsupported: Bool = false
|
||||
|
||||
/// The delivery-status indicator to render for this bubble. Outgoing only;
|
||||
/// `isUnreadOutgoing` already encodes "sent, unread, not Saved Messages".
|
||||
|
|
@ -184,7 +187,8 @@ func messageRows(
|
|||
userNames: userNames
|
||||
),
|
||||
senderColorIndex: sender?.colorIndex,
|
||||
isUnreadOutgoing: !isSavedMessages && msg.isOutgoing && msg.sendingState == .sent && msg.id > lastReadOutboxMessageId
|
||||
isUnreadOutgoing: !isSavedMessages && msg.isOutgoing && msg.sendingState == .sent && msg.id > lastReadOutboxMessageId,
|
||||
isUnsupported: isUnsupportedContent(msg.content)
|
||||
)))
|
||||
}
|
||||
return rows
|
||||
|
|
|
|||
|
|
@ -40,6 +40,18 @@ private func autoDeleteDuration(seconds: Int) -> String {
|
|||
return mo == 1 ? "1 month" : "\(mo) months"
|
||||
}
|
||||
|
||||
/// "{m} m" under 1 km, else whole "{km} km".
|
||||
private func distanceLabel(meters: Int) -> String {
|
||||
if meters < 1000 { return "\(meters) m" }
|
||||
return "\(meters / 1000) km"
|
||||
}
|
||||
|
||||
/// "channel" when the chat is a broadcast channel; "group" otherwise (incl. nil).
|
||||
private func groupNoun(_ chatType: ChatType?) -> String {
|
||||
if case .chatTypeSupergroup(let s) = chatType, s.isChannel { return "channel" }
|
||||
return "group"
|
||||
}
|
||||
|
||||
/// Renders the pinned-snippet phrase used in "{Actor} pinned ...". Returns
|
||||
/// "«{snippet}»" for text content (with truncation to 24 characters +
|
||||
/// ellipsis), "a {type}" for non-text recognized content, or "a message" for
|
||||
|
|
@ -105,7 +117,10 @@ private func targetList(_ ids: [Int64], userNames: [Int64: String]) -> String {
|
|||
/// Renders the centered-italic line for a service message, or `nil` for content
|
||||
/// that should render as a regular bubble. Pure: same inputs always produce the
|
||||
/// same output. See `docs/superpowers/specs/2026-05-16-service-messages-design.md`
|
||||
/// for the full classification and string set.
|
||||
/// for the original classification and string set, and
|
||||
/// `docs/superpowers/specs/2026-05-29-extend-service-lines-design.md` for the
|
||||
/// extended coverage (theme/background/boost/forum-topic/upgrade/game-score/
|
||||
/// content-protection/sharing/web-app/suggest-photo/proximity/giveaway).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - msg: the cached message carrying senderId + content payload.
|
||||
|
|
@ -225,6 +240,86 @@ func serviceLineText(
|
|||
}
|
||||
return "Group created"
|
||||
|
||||
case .messageForumTopicCreated(let m):
|
||||
return withActor("created topic «\(m.name)»", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageForumTopicEdited(let m):
|
||||
if !m.name.isEmpty {
|
||||
return withActor("changed the topic name to «\(m.name)»", actor: actor, includeActor: includeActor)
|
||||
}
|
||||
if m.editIconCustomEmojiId {
|
||||
return withActor("changed the topic icon", actor: actor, includeActor: includeActor)
|
||||
}
|
||||
return withActor("edited the topic", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageForumTopicIsClosedToggled(let m):
|
||||
return withActor(m.isClosed ? "closed the topic" : "reopened the topic",
|
||||
actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageForumTopicIsHiddenToggled(let m):
|
||||
return withActor(m.isHidden ? "hid the topic" : "unhid the topic",
|
||||
actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageChatSetTheme(let m):
|
||||
guard let theme = m.theme else {
|
||||
return withActor("disabled the chat theme", actor: actor, includeActor: includeActor)
|
||||
}
|
||||
if case .chatThemeEmoji(let e) = theme {
|
||||
return withActor("changed the chat theme to \(e.name)", actor: actor, includeActor: includeActor)
|
||||
}
|
||||
return withActor("changed the chat theme", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageChatSetBackground:
|
||||
return withActor("changed the chat background", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageChatBoost(let m):
|
||||
let noun = groupNoun(chatType)
|
||||
if m.boostCount == 1 {
|
||||
return withActor("boosted the \(noun)", actor: actor, includeActor: includeActor)
|
||||
}
|
||||
return withActor("boosted the \(noun) \(m.boostCount) times", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageChatHasProtectedContentToggled(let m):
|
||||
return withActor(m.newHasProtectedContent ? "enabled content protection" : "disabled content protection",
|
||||
actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageChatUpgradeTo, .messageChatUpgradeFrom:
|
||||
// Chat-lifecycle event — actor-less (Telegram convention), ignores includeActor.
|
||||
return "Group upgraded to a supergroup"
|
||||
|
||||
case .messageChatShared:
|
||||
return withActor("shared a chat", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageUsersShared(let m):
|
||||
let action = m.users.count == 1 ? "shared a user" : "shared \(m.users.count) users"
|
||||
return withActor(action, actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageBotWriteAccessAllowed:
|
||||
return withActor("allowed this bot to message you", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageWebAppDataSent:
|
||||
return withActor("sent data to the bot", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageSuggestProfilePhoto:
|
||||
return withActor("suggested a new profile photo", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageGameScore(let m):
|
||||
return withActor("scored \(m.score)", actor: actor, includeActor: includeActor)
|
||||
|
||||
case .messageProximityAlertTriggered(let m):
|
||||
// Actor-less: the line names traveler + watcher, not the message sender.
|
||||
let traveler = serviceActor(senderId: m.travelerId, selfUserId: selfUserId, userNames: userNames)
|
||||
let watcher = serviceActor(senderId: m.watcherId, selfUserId: selfUserId, userNames: userNames)
|
||||
return "\(traveler) is within \(distanceLabel(meters: m.distance)) of \(watcher)"
|
||||
|
||||
case .messageGiveawayCreated(let m):
|
||||
// Actor-less — giveaways are posted by channels.
|
||||
return m.starCount > 0 ? "Stars giveaway started" : "Giveaway started"
|
||||
|
||||
case .messageGiveawayCompleted(let m):
|
||||
let winners = m.winnerCount == 1 ? "1 winner" : "\(m.winnerCount) winners"
|
||||
return "Giveaway ended — \(winners)"
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Placeholder bubble for message content types the app does not render
|
||||
/// (`isUnsupportedContent`). Mirrors the standard text-bubble chrome —
|
||||
/// `BubbleShape` corner radius + min-size pill, incoming/outgoing fill via
|
||||
/// `BubbleStyle` — and shows an italic, dimmed "Unsupported message" line.
|
||||
/// Renders the reply header when present; captions are intentionally ignored.
|
||||
struct UnsupportedBubbleView: View {
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
|
||||
private var style: BubbleStyle { .resolve(isOutgoing: isOutgoing) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: isOutgoing ? .trailing : .leading, spacing: 2) {
|
||||
if let header = replyHeader {
|
||||
ReplyHeaderView(header: header, style: style)
|
||||
}
|
||||
Text("Unsupported message")
|
||||
.font(.caption)
|
||||
.italic()
|
||||
.foregroundStyle(style.content.opacity(0.6))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 4)
|
||||
.frame(minWidth: BubbleShape.minSize, minHeight: BubbleShape.minSize)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: BubbleShape.cornerRadius)
|
||||
.fill(style.fill)
|
||||
)
|
||||
.foregroundStyle(style.content)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Unsupported — incoming") {
|
||||
UnsupportedBubbleView(isOutgoing: false, replyHeader: nil)
|
||||
.bubblePreview()
|
||||
}
|
||||
|
||||
#Preview("Unsupported — outgoing") {
|
||||
UnsupportedBubbleView(isOutgoing: true, replyHeader: nil)
|
||||
.bubblePreview()
|
||||
}
|
||||
|
||||
#Preview("Unsupported — incoming with reply") {
|
||||
UnsupportedBubbleView(
|
||||
isOutgoing: false,
|
||||
replyHeader: ReplyHeader(
|
||||
senderName: "Bob",
|
||||
snippet: "anchor message earlier in the chat",
|
||||
minithumbnail: nil, isOutgoing: false
|
||||
)
|
||||
)
|
||||
.bubblePreview()
|
||||
}
|
||||
#endif
|
||||
|
|
@ -96,14 +96,38 @@ final class TDClient {
|
|||
self.delegate = delegate
|
||||
}
|
||||
|
||||
func submitPassword(_ password: String) async {
|
||||
guard let client else { return }
|
||||
lastError = nil
|
||||
/// Submits the cloud password. Returns `nil` on success, or a user-facing
|
||||
/// error message (see `passwordSubmitErrorMessage`) on failure. Does not
|
||||
/// touch the shared `lastError` — the password screen owns its own alert.
|
||||
func submitPassword(_ password: String) async -> String? {
|
||||
guard let client else { return nil }
|
||||
do {
|
||||
_ = try await client.checkAuthenticationPassword(password: password)
|
||||
return nil
|
||||
} catch {
|
||||
logger.warning("checkAuthenticationPassword failed: \(error.localizedDescription, privacy: .public)")
|
||||
lastError = humanMessage(error)
|
||||
return passwordSubmitErrorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-requests QR authentication from the `waitPassword` state, driving
|
||||
/// TDLib back to `waitOtherDeviceConfirmation`. Valid because
|
||||
/// `requestQrCodeAuthentication` is callable from `authorizationStateWaitPassword`
|
||||
/// when no auth query is pending. Returns `nil` on success, or a user-facing
|
||||
/// error message on failure. Does not touch `authState` — the QR screen
|
||||
/// renders via the normal `updateAuthorizationState` path; on failure the
|
||||
/// caller (the password screen) shows the message in its own alert and stays
|
||||
/// put, so we deliberately avoid the `.failed` transition that
|
||||
/// `requestQrCodeAuthenticationIfNeeded()` performs.
|
||||
func returnToQrCode() async -> String? {
|
||||
guard let client else { return nil }
|
||||
logger.notice("returnToQrCode requested by UI")
|
||||
do {
|
||||
_ = try await client.requestQrCodeAuthentication(otherUserIds: [])
|
||||
return nil
|
||||
} catch {
|
||||
logger.warning("returnToQrCode failed: \(error.localizedDescription, privacy: .public)")
|
||||
return humanMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import TDLibKit
|
|||
@main
|
||||
struct TgwatchApp: App {
|
||||
@State private var manager: AccountManager
|
||||
/// True when running inside an XCTest process. Computed once and stored so
|
||||
/// both `init()` and `body` can reference the same value without repeating
|
||||
/// the `NSClassFromString` lookup.
|
||||
private let isUnderXCTest: Bool = NSClassFromString("XCTestCase") != nil
|
||||
|
||||
@MainActor
|
||||
init() {
|
||||
|
|
@ -14,7 +18,6 @@ struct TgwatchApp: App {
|
|||
// `td_receive` is single-thread-global — see CLAUDE.md gotcha). Hand
|
||||
// the test process a no-bootstrap manager; tests never drive the
|
||||
// SwiftUI scene, so its `factory` is never invoked.
|
||||
let isUnderXCTest = NSClassFromString("XCTestCase") != nil
|
||||
let factory: any TDClientFactory
|
||||
if isUnderXCTest {
|
||||
factory = NoopTDClientFactory()
|
||||
|
|
@ -38,8 +41,11 @@ struct TgwatchApp: App {
|
|||
.environment(client)
|
||||
.environment(manager)
|
||||
.id(manager.activeAccountId)
|
||||
} else {
|
||||
AccountsEmptyView()
|
||||
} else if !isUnderXCTest {
|
||||
// Under XCTest the scene is not test-driven; suppress
|
||||
// AccountBootstrapView so its .task doesn't call
|
||||
// ensureAccountExists() → factory.make() via NoopTDClientFactory.
|
||||
AccountBootstrapView()
|
||||
.environment(manager)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,12 @@
|
|||
1773B32101D7CB51E65BE91B /* PhotoVisual.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0240D336BD11E2613A457C43 /* PhotoVisual.swift */; };
|
||||
1D1502C120B3AF8B91C1A91D /* AVRecordingBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9127473C7DAD18736234B3 /* AVRecordingBackend.swift */; };
|
||||
1D4E75A6F594617ACD668616 /* SampleStickers in Resources */ = {isa = PBXBuildFile; fileRef = 72755AB1D5DB932DDC3CE2D3 /* SampleStickers */; };
|
||||
1F7CEC07D0DBC5FD130AF942 /* AccountBootstrapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377FDFE2ED14BCD51D22429F /* AccountBootstrapView.swift */; };
|
||||
20167DF49E7A2E543B274E76 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A065521FE790E0BF29D1B3B6 /* Secrets.swift */; };
|
||||
20863684FA2F51495BB2A9A3 /* AccountsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 148D11BBC0B40441DCC8EA0A /* AccountsListView.swift */; };
|
||||
25F6B3EFED8EDDD9983CB0F7 /* PollVoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF0F4F15CB43635717B31B92 /* PollVoteView.swift */; };
|
||||
2A0FDF9EF2B8BE9F4C46FD4C /* RLottieKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5083D73573D84E386C2E3B4C /* RLottieKit */; };
|
||||
2E2B46A1FFFDC24F47FF33D4 /* ChatOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED3696C9BA38845E621A1B67 /* ChatOpener.swift */; };
|
||||
3679D6EA591522207E69F8A5 /* ChatListTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCDFBD19F9CAA87E938BADC /* ChatListTimestamp.swift */; };
|
||||
39A386FFBD00EEE14B2B33D5 /* MessageFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09479749C40E78AD852AF8B /* MessageFormatters.swift */; };
|
||||
3BA42E87FCC662196BB80B64 /* MessageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F01C7C9A4B9948C0BB710E2 /* MessageRow.swift */; };
|
||||
|
|
@ -54,7 +56,6 @@
|
|||
787D692CF045D92B0DEFC1F2 /* TDClientFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 695248F618E8ABD4F9840F92 /* TDClientFactory.swift */; };
|
||||
79A01936CFB9168EC5ABD641 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BFBE65008CE5037F6DF19A1 /* VideoPlayerView.swift */; };
|
||||
7BF1C89F67290EBAF50900F8 /* ChatRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29C2B6C3E8643BAF27074570 /* ChatRowView.swift */; };
|
||||
7C375DD06A2EF2BFCCC62C7D /* AccountsEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9F5A0906CC20AEE02CA838 /* AccountsEmptyView.swift */; };
|
||||
7D835D5CE3C3B58799AFAF36 /* StickerPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9196F9ED24A5500CE7A073D9 /* StickerPickerView.swift */; };
|
||||
7DD192512B16EA3515244C22 /* VoicePlaybackController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E7405292B1E44847A760AC5 /* VoicePlaybackController.swift */; };
|
||||
80C0C6603FDC96FE26ED7658 /* VideoNotePlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A73BF3FB58AE7A1F8891DB8 /* VideoNotePlayerView.swift */; };
|
||||
|
|
@ -82,6 +83,7 @@
|
|||
B14FB831F58FBE797B2E5E22 /* UnreadDividerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 677A0F85CE4C41873A7774C2 /* UnreadDividerView.swift */; };
|
||||
B60D32384AEF65DF99757144 /* PasswordEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E86853697764EDEB0866DA2 /* PasswordEntryView.swift */; };
|
||||
B777F8B2ACBABCC223BE25A4 /* VoiceRecordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604A4F1FAB5820C300C709D5 /* VoiceRecordView.swift */; };
|
||||
B79AB6E8675A1A32B8DD9E4A /* UnsupportedBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8545CFD4F2B01CDACB225974 /* UnsupportedBubbleView.swift */; };
|
||||
B8FB9BDD812396930990235C /* SecretsValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E049ECC956A583A92C5C7CB /* SecretsValidator.swift */; };
|
||||
B9F6D473AA0F1DAF77799E77 /* QrLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D3A8FBA5EBDBFDE5AA2FFEC /* QrLoginView.swift */; };
|
||||
C00221119F0E81D881582EEE /* VideoNoteVisual.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D49A955BB3D23881CE8E150 /* VideoNoteVisual.swift */; };
|
||||
|
|
@ -127,7 +129,6 @@
|
|||
148D11BBC0B40441DCC8EA0A /* AccountsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsListView.swift; sourceTree = "<group>"; };
|
||||
182DA99AD90B93DE2FB37A41 /* ChatHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHistoryStore.swift; sourceTree = "<group>"; };
|
||||
1932CC94F39BD57A56E59C08 /* ChatRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRow.swift; sourceTree = "<group>"; };
|
||||
1E9F5A0906CC20AEE02CA838 /* AccountsEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsEmptyView.swift; sourceTree = "<group>"; };
|
||||
20DF1E1C15EBF3931AC3843C /* VideoVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoVisual.swift; sourceTree = "<group>"; };
|
||||
22AAEA9EF0EC8A548BB916EC /* StickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerRow.swift; sourceTree = "<group>"; };
|
||||
23B6E36BD1D21CA9073903BF /* AccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManager.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -137,6 +138,7 @@
|
|||
3448938C1F69F0D9DE679238 /* ChatPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreview.swift; sourceTree = "<group>"; };
|
||||
35B32555B3686A7A4884B215 /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = "<group>"; };
|
||||
36FCB0CB6A9A867B1E8BD673 /* OpusKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OpusKit; path = Packages/OpusKit; sourceTree = SOURCE_ROOT; };
|
||||
377FDFE2ED14BCD51D22429F /* AccountBootstrapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBootstrapView.swift; sourceTree = "<group>"; };
|
||||
3B4D75BC5502D8D705E859FB /* LocationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationProvider.swift; sourceTree = "<group>"; };
|
||||
3DAB96C768DF82BCF185BAE2 /* AudioPlaybackController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackController.swift; sourceTree = "<group>"; };
|
||||
3E86853697764EDEB0866DA2 /* PasswordEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntryView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -171,6 +173,7 @@
|
|||
7889969AFF462911B9828843 /* MessageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRowView.swift; sourceTree = "<group>"; };
|
||||
78DB439A3F9A5EA35ECDFB86 /* PhotoViewerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoViewerView.swift; sourceTree = "<group>"; };
|
||||
7B0F1DD19A57CC68887E4F98 /* ChatListKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListKey.swift; sourceTree = "<group>"; };
|
||||
8545CFD4F2B01CDACB225974 /* UnsupportedBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedBubbleView.swift; sourceTree = "<group>"; };
|
||||
8724043C70C9B97BCBA4712F /* ReviewVoiceBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewVoiceBubble.swift; sourceTree = "<group>"; };
|
||||
8D49A955BB3D23881CE8E150 /* VideoNoteVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteVisual.swift; sourceTree = "<group>"; };
|
||||
8F01C7C9A4B9948C0BB710E2 /* MessageRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRow.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -217,6 +220,7 @@
|
|||
E855E4C62401AD1CF41D1835 /* DaySeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaySeparatorView.swift; sourceTree = "<group>"; };
|
||||
EB5C5FEFC91B0D5FDF164032 /* TDClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDClient.swift; sourceTree = "<group>"; };
|
||||
ECA5A0C122ECD5BBB6EBBBBD /* AvatarVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarVisual.swift; sourceTree = "<group>"; };
|
||||
ED3696C9BA38845E621A1B67 /* ChatOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatOpener.swift; sourceTree = "<group>"; };
|
||||
F424F5E873CD24E281387693 /* ErrorMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMapping.swift; sourceTree = "<group>"; };
|
||||
F595E3DA02BF871A797A649A /* ReplyHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyHeader.swift; sourceTree = "<group>"; };
|
||||
F59F34916FE776927C3B2CB1 /* ServiceLineFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceLineFormatter.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -299,10 +303,10 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
BABCBE66AF47816CA3518036 /* Account.swift */,
|
||||
377FDFE2ED14BCD51D22429F /* AccountBootstrapView.swift */,
|
||||
6DDFFE71E0C32258348A2510 /* AccountListProjection.swift */,
|
||||
23B6E36BD1D21CA9073903BF /* AccountManager.swift */,
|
||||
42427DC1E64A2F55DE8BE345 /* AccountRegistry.swift */,
|
||||
1E9F5A0906CC20AEE02CA838 /* AccountsEmptyView.swift */,
|
||||
148D11BBC0B40441DCC8EA0A /* AccountsListView.swift */,
|
||||
6B89EE0100295ED590F73723 /* AccountSwitcherSheet.swift */,
|
||||
695248F618E8ABD4F9840F92 /* TDClientFactory.swift */,
|
||||
|
|
@ -371,6 +375,7 @@
|
|||
F59F34916FE776927C3B2CB1 /* ServiceLineFormatter.swift */,
|
||||
DF393D904BF677AC5721EC7E /* ServiceMessageView.swift */,
|
||||
677A0F85CE4C41873A7774C2 /* UnreadDividerView.swift */,
|
||||
8545CFD4F2B01CDACB225974 /* UnsupportedBubbleView.swift */,
|
||||
0386BF4C7B1D4BDAE0B73509 /* VideoBubbleView.swift */,
|
||||
D4AF5A180BB3A37832AC5349 /* VideoNoteBubbleView.swift */,
|
||||
9A73BF3FB58AE7A1F8891DB8 /* VideoNotePlayerView.swift */,
|
||||
|
|
@ -413,6 +418,7 @@
|
|||
F824052186720912B44D8263 /* ChatListStore.swift */,
|
||||
0CCDFBD19F9CAA87E938BADC /* ChatListTimestamp.swift */,
|
||||
7108EAF6A69D8D59376A8914 /* ChatListView.swift */,
|
||||
ED3696C9BA38845E621A1B67 /* ChatOpener.swift */,
|
||||
3448938C1F69F0D9DE679238 /* ChatPreview.swift */,
|
||||
1932CC94F39BD57A56E59C08 /* ChatRow.swift */,
|
||||
29C2B6C3E8643BAF27074570 /* ChatRowView.swift */,
|
||||
|
|
@ -508,11 +514,11 @@
|
|||
files = (
|
||||
1D1502C120B3AF8B91C1A91D /* AVRecordingBackend.swift in Sources */,
|
||||
0BE4BEE5802CD229F61FF464 /* Account.swift in Sources */,
|
||||
1F7CEC07D0DBC5FD130AF942 /* AccountBootstrapView.swift in Sources */,
|
||||
7059458F1317797724013AC8 /* AccountListProjection.swift in Sources */,
|
||||
AAE21A407D0201F0A8630FD9 /* AccountManager.swift in Sources */,
|
||||
8364AFF20E93D890EFEF7C41 /* AccountRegistry.swift in Sources */,
|
||||
881BA28087741D8F6B406FE9 /* AccountSwitcherSheet.swift in Sources */,
|
||||
7C375DD06A2EF2BFCCC62C7D /* AccountsEmptyView.swift in Sources */,
|
||||
20863684FA2F51495BB2A9A3 /* AccountsListView.swift in Sources */,
|
||||
039B8E2B7418D9004EAF9533 /* AttachmentSheet.swift in Sources */,
|
||||
D1E96E63A3AE93646AD3F532 /* AudioBubbleView.swift in Sources */,
|
||||
|
|
@ -533,6 +539,7 @@
|
|||
81C79089068A1B436DFFD2EF /* ChatListStore.swift in Sources */,
|
||||
3679D6EA591522207E69F8A5 /* ChatListTimestamp.swift in Sources */,
|
||||
4938F79EA33D5C8041A659C3 /* ChatListView.swift in Sources */,
|
||||
2E2B46A1FFFDC24F47FF33D4 /* ChatOpener.swift in Sources */,
|
||||
A8EF6F72C78B619893C05AFE /* ChatPreview.swift in Sources */,
|
||||
E48523AFA2E23409CC0C2C2C /* ChatRow.swift in Sources */,
|
||||
7BF1C89F67290EBAF50900F8 /* ChatRowView.swift in Sources */,
|
||||
|
|
@ -590,6 +597,7 @@
|
|||
DDB206E2134B52F472D5E9FE /* TDClient.swift in Sources */,
|
||||
787D692CF045D92B0DEFC1F2 /* TDClientFactory.swift in Sources */,
|
||||
B14FB831F58FBE797B2E5E22 /* UnreadDividerView.swift in Sources */,
|
||||
B79AB6E8675A1A32B8DD9E4A /* UnsupportedBubbleView.swift in Sources */,
|
||||
DD9FB33F951CC225F8D5B3EE /* UserNamesStore.swift in Sources */,
|
||||
939315A8C737DB06ED040E4F /* VideoBubbleView.swift in Sources */,
|
||||
074BE3F4D6AB9EA220A8EDF2 /* VideoNoteBubbleView.swift in Sources */,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue