Update tgwatch

This commit is contained in:
isaac 2026-05-29 16:08:23 +02:00
parent 354e1c7a66
commit 79ee96306b
18 changed files with 649 additions and 195 deletions

View file

@ -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

View file

@ -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

View file

@ -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

Before After
Before After

View file

@ -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-stateBool 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
}
}
}

View file

@ -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 {

View 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
}
}
}

View file

@ -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."
}

View file

@ -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 {

View file

@ -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

View file

@ -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.

View file

@ -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-paintdivider 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-paintdivider 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())
}
}
}
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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 */,