diff --git a/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountBootstrapView.swift b/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountBootstrapView.swift new file mode 100644 index 0000000000..1f0740b748 --- /dev/null +++ b/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountBootstrapView.swift @@ -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 diff --git a/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountManager.swift b/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountManager.swift index 8be3efe193..b32de42e5a 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountManager.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountManager.swift @@ -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 diff --git a/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountsEmptyView.swift b/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountsEmptyView.swift deleted file mode 100644 index 310419222e..0000000000 --- a/Telegram/WatchApp/tgwatch Watch App/Accounts/AccountsEmptyView.swift +++ /dev/null @@ -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) {} - } - } -} diff --git a/Telegram/WatchApp/tgwatch Watch App/Assets.xcassets/ChatBG.imageset/ChatBG.png b/Telegram/WatchApp/tgwatch Watch App/Assets.xcassets/ChatBG.imageset/ChatBG.png index 92c90ecdf9..5b17fa7ff6 100644 Binary files a/Telegram/WatchApp/tgwatch Watch App/Assets.xcassets/ChatBG.imageset/ChatBG.png and b/Telegram/WatchApp/tgwatch Watch App/Assets.xcassets/ChatBG.imageset/ChatBG.png differ diff --git a/Telegram/WatchApp/tgwatch Watch App/Auth/PasswordEntryView.swift b/Telegram/WatchApp/tgwatch Watch App/Auth/PasswordEntryView.swift index 7c342256ec..36b26e4551 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Auth/PasswordEntryView.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Auth/PasswordEntryView.swift @@ -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 { + 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 } } } diff --git a/Telegram/WatchApp/tgwatch Watch App/Chats/ChatListView.swift b/Telegram/WatchApp/tgwatch Watch App/Chats/ChatListView.swift index 5f41b221b7..01906670fa 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Chats/ChatListView.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Chats/ChatListView.swift @@ -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 { diff --git a/Telegram/WatchApp/tgwatch Watch App/Chats/ChatOpener.swift b/Telegram/WatchApp/tgwatch Watch App/Chats/ChatOpener.swift new file mode 100644 index 0000000000..406fcee0af --- /dev/null +++ b/Telegram/WatchApp/tgwatch Watch App/Chats/ChatOpener.swift @@ -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 + } + } +} diff --git a/Telegram/WatchApp/tgwatch Watch App/ErrorMapping.swift b/Telegram/WatchApp/tgwatch Watch App/ErrorMapping.swift index 0f89ed8557..8c0a2c18a1 100644 --- a/Telegram/WatchApp/tgwatch Watch App/ErrorMapping.swift +++ b/Telegram/WatchApp/tgwatch Watch App/ErrorMapping.swift @@ -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_` 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." +} diff --git a/Telegram/WatchApp/tgwatch Watch App/Messages/ChatHistoryStore.swift b/Telegram/WatchApp/tgwatch Watch App/Messages/ChatHistoryStore.swift index 87ddf2b953..4efc14ad25 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Messages/ChatHistoryStore.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Messages/ChatHistoryStore.swift @@ -39,6 +39,7 @@ final class ChatHistoryStore { private var files: [Int: File] = [:] private var trackedFileIds: Set = [] 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 { diff --git a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageBubbleView.swift b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageBubbleView.swift index aa1322c4e7..59622835cf 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageBubbleView.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageBubbleView.swift @@ -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 diff --git a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageFormatters.swift b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageFormatters.swift index c7460e1645..674a8cbbe2 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageFormatters.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageFormatters.swift @@ -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. diff --git a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageListView.swift b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageListView.swift index d653925466..3313d81858 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageListView.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageListView.swift @@ -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()) + } + } } } } diff --git a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageRow.swift b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageRow.swift index 1a206b5ea7..17962fd892 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageRow.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageRow.swift @@ -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 diff --git a/Telegram/WatchApp/tgwatch Watch App/Messages/ServiceLineFormatter.swift b/Telegram/WatchApp/tgwatch Watch App/Messages/ServiceLineFormatter.swift index 0224b26f45..efa097ebeb 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Messages/ServiceLineFormatter.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Messages/ServiceLineFormatter.swift @@ -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 } diff --git a/Telegram/WatchApp/tgwatch Watch App/Messages/UnsupportedBubbleView.swift b/Telegram/WatchApp/tgwatch Watch App/Messages/UnsupportedBubbleView.swift new file mode 100644 index 0000000000..b2d4eeb0e8 --- /dev/null +++ b/Telegram/WatchApp/tgwatch Watch App/Messages/UnsupportedBubbleView.swift @@ -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 diff --git a/Telegram/WatchApp/tgwatch Watch App/TDClient.swift b/Telegram/WatchApp/tgwatch Watch App/TDClient.swift index 809f629dc5..9dd589f944 100644 --- a/Telegram/WatchApp/tgwatch Watch App/TDClient.swift +++ b/Telegram/WatchApp/tgwatch Watch App/TDClient.swift @@ -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) } } diff --git a/Telegram/WatchApp/tgwatch Watch App/tgwatchApp.swift b/Telegram/WatchApp/tgwatch Watch App/tgwatchApp.swift index dfc8056beb..668da7a0bd 100644 --- a/Telegram/WatchApp/tgwatch Watch App/tgwatchApp.swift +++ b/Telegram/WatchApp/tgwatch Watch App/tgwatchApp.swift @@ -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) } } diff --git a/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj b/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj index 6514e9d26f..b1af92c605 100644 --- a/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj +++ b/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj @@ -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 = ""; }; 182DA99AD90B93DE2FB37A41 /* ChatHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHistoryStore.swift; sourceTree = ""; }; 1932CC94F39BD57A56E59C08 /* ChatRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRow.swift; sourceTree = ""; }; - 1E9F5A0906CC20AEE02CA838 /* AccountsEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsEmptyView.swift; sourceTree = ""; }; 20DF1E1C15EBF3931AC3843C /* VideoVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoVisual.swift; sourceTree = ""; }; 22AAEA9EF0EC8A548BB916EC /* StickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerRow.swift; sourceTree = ""; }; 23B6E36BD1D21CA9073903BF /* AccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManager.swift; sourceTree = ""; }; @@ -137,6 +138,7 @@ 3448938C1F69F0D9DE679238 /* ChatPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreview.swift; sourceTree = ""; }; 35B32555B3686A7A4884B215 /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; 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 = ""; }; 3B4D75BC5502D8D705E859FB /* LocationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationProvider.swift; sourceTree = ""; }; 3DAB96C768DF82BCF185BAE2 /* AudioPlaybackController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackController.swift; sourceTree = ""; }; 3E86853697764EDEB0866DA2 /* PasswordEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntryView.swift; sourceTree = ""; }; @@ -171,6 +173,7 @@ 7889969AFF462911B9828843 /* MessageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRowView.swift; sourceTree = ""; }; 78DB439A3F9A5EA35ECDFB86 /* PhotoViewerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoViewerView.swift; sourceTree = ""; }; 7B0F1DD19A57CC68887E4F98 /* ChatListKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListKey.swift; sourceTree = ""; }; + 8545CFD4F2B01CDACB225974 /* UnsupportedBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedBubbleView.swift; sourceTree = ""; }; 8724043C70C9B97BCBA4712F /* ReviewVoiceBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewVoiceBubble.swift; sourceTree = ""; }; 8D49A955BB3D23881CE8E150 /* VideoNoteVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteVisual.swift; sourceTree = ""; }; 8F01C7C9A4B9948C0BB710E2 /* MessageRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRow.swift; sourceTree = ""; }; @@ -217,6 +220,7 @@ E855E4C62401AD1CF41D1835 /* DaySeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaySeparatorView.swift; sourceTree = ""; }; EB5C5FEFC91B0D5FDF164032 /* TDClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDClient.swift; sourceTree = ""; }; ECA5A0C122ECD5BBB6EBBBBD /* AvatarVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarVisual.swift; sourceTree = ""; }; + ED3696C9BA38845E621A1B67 /* ChatOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatOpener.swift; sourceTree = ""; }; F424F5E873CD24E281387693 /* ErrorMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMapping.swift; sourceTree = ""; }; F595E3DA02BF871A797A649A /* ReplyHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyHeader.swift; sourceTree = ""; }; F59F34916FE776927C3B2CB1 /* ServiceLineFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceLineFormatter.swift; sourceTree = ""; }; @@ -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 */,