diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
index 335cc89..0e314d9 100644
--- a/.github/workflows/ios.yml
+++ b/.github/workflows/ios.yml
@@ -1,34 +1,42 @@
-name: iOS CI
-
+name: Build Unsigned iOS ipa
on:
push:
- branches: [ main ]
+ branches:
+ - main
+ - develop
+ - issue-*
pull_request:
- branches: [ main ]
-
+ branches:
+ - main
+ - develop
jobs:
- build-and-test:
- name: Build and Test SwiftUI App
- runs-on: macos-26 # Uses Apple Silicon M1/M2 runners
-
+ build-ios:
+ name: Build Unsigned iOS IPA
+ runs-on: macos-26
steps:
- # Step 1: Check out the repository code
- - name: Checkout Repository
+ - name: Checkout repository
uses: actions/checkout@v4
-
- # Step 2: Select the correct Xcode version
- - name: Set Xcode Version
+ - name: Set up Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
-
- # Step 3: Load dependencies if using Swift Package Manager
- - name: Resolve Swift Packages
- run: xcodebuild -resolvePackageDependencies -project Luma.xcodeproj -scheme Luma
-
- # Step 4: Build and run tests using xcodebuild
- - name: Build and Test
+ - name: Build Xcode Workspace
run: |
- xcodebuild test \
+ xcodebuild build \
-project Luma.xcodeproj \
-scheme Luma \
- -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \
- -quiet
+ -configuration Release \
+ -destination 'generic/platform=iOS' \
+ -derivedDataPath build/DerivedData \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ CODE_SIGN_IDENTITY="" \
+ PROVISIONING_PROFILE_SPECIFIER=""
+ - name: Package into IPA
+ run: |
+ mkdir -p build/Payload
+ mv build/DerivedData/Build/Products/Release-iphoneos/Luma.app build/Payload/
+ cd build && zip -r Luma.ipa Payload/
+ - name: Upload iOS Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: Luma${{ github.run_id }}
+ path: build/Luma.ipa
diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml
new file mode 100644
index 0000000..54131d8
--- /dev/null
+++ b/.github/workflows/macos.yml
@@ -0,0 +1,37 @@
+name: Build Unsigned macOS App
+on:
+ push:
+ branches:
+ - main
+ - develop
+ - issue-*
+ pull_request:
+ branches:
+ - main
+ - develop
+jobs:
+ build-macos:
+ name: Build macOS ZIP
+ runs-on: macos-26
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ - name: Set up Xcode version
+ run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
+ - name: Build macOS App
+ run: |
+ xcodebuild build \
+ -project Luma.xcodeproj \
+ -scheme LumaMac \
+ -configuration Release \
+ -derivedDataPath build/DerivedData \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ CODE_SIGN_IDENTITY=""
+ - name: Prepare macOS Artifact
+ run: mkdir -p upload && cp -rv build/DerivedData/Build/Products/Release/Luma.app upload/
+ - name: Upload macOS Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: Luma
+ path: upload
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..2ea372d
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,36 @@
+name: iOS CI
+on:
+ push:
+ branches:
+ - main
+ - develop
+ - issue-*
+ pull_request:
+ branches:
+ - main
+ - develop
+jobs:
+ build-and-test:
+ name: Build and Test SwiftUI App
+ runs-on: macos-26
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+ - name: Set Xcode Version
+ run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
+ - name: Resolve Swift Packages
+ run: xcodebuild -resolvePackageDependencies -project Luma.xcodeproj -scheme Luma
+ - name: Build and Test
+ run: |
+ xcodebuild test \
+ -project Luma.xcodeproj \
+ -scheme Luma \
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \
+ -only-testing:LumaTests \
+ -resultBundlePath TestResults.xcresult
+ - name: Upload Test Results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: Luma-TestResults
+ path: TestResults.xcresult
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..295c951
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,74 @@
+# Repository Guidelines
+
+Luma is an XMPP client for iOS, iPadOS, macOS, and watchOS, built with SwiftUI
+and the Martin / MartinOMEMO libraries. The Xcode project is generated from
+`project.yml` by XcodeGen — never edit `Luma.xcodeproj` directly.
+
+## Project Structure
+
+- `Sources/App/` — iOS/macOS app entry point (`LumaApp.swift`).
+- `Sources/Shared/` — shared code:
+ - `Models/` — value types and pure policy enums (`ArchiveSyncPagination`,
+ `ChatTypingPolicy`, …) plus the SwiftData `@Model` classes
+ (`ChatMessage`, `Conversation`).
+ - `UI/` — SwiftUI views; chat/contact lists read SwiftData through
+ `@Query` (`MainTabView`, `ChatView`, `ForwardMessageView`); the main
+ screen is Telegram-style: an iOS native `TabView` tab bar
+ (Контакты/Звонки/Чаты/Настройки) with inline search fields above
+ the lists, and a
+ macOS Telegram Desktop-style split view (`MainSplitView`: sidebar
+ with menu/search/list plus a chat detail pane).
+ - `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store,
+ `LumaOMEMO2Module` (urn:xmpp:omemo:2 wire format: AES-256-CBC + HMAC
+ payload, SCE envelope, device/bundle PEP nodes — shares the Double
+ Ratchet sessions with the legacy module), `SASLprep` (RFC 4013),
+ SCRAM-SHA-512 mechanism, SASL failure observer/messages.
+ - `Persistence/` — `ArchiveStore` (per-account SwiftData container),
+ `ArchiveMetadataRecord` (durable MAM checkpoint metadata),
+ `LegacyArchiveImporter` (one-time legacy JSON snapshot migration),
+ preferences.
+ - `Services/`, `Security/` — media, notifications, credentials.
+- `Sources/Watch/` — single-target watchOS app.
+- `Tests/` — XCTest unit tests.
+- `Resources/`, `Config/`, `Brand/`, `Docs/` — assets, plists/entitlements,
+ icons, architecture/security docs.
+
+## Build, Test & Development
+
+- `make project` — regenerate `Luma.xcodeproj` from `project.yml`
+ (requires `brew install xcodegen`).
+- `make open` — regenerate and open Xcode.
+- `make verify` — run `Scripts/verify.sh`: grep-based structural invariants
+ plus an optional simulator build.
+- `make clean` — remove the generated project and DerivedData.
+- `xcodebuild test -project Luma.xcodeproj -scheme Luma \
+-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max'` — build and
+ run the test suite.
+
+## Coding Style
+
+- Swift 5.9; 4-space indentation (set in `project.yml`).
+- UI-facing state is `@MainActor` (`AppModel`, `XMPPService`); keep heavy work
+ (OMEMO decryption, media, persistence) off the main actor.
+- Prefer small, pure value types and unit-testable policy enums over inline
+ branching.
+- UI strings are Russian; identifiers and code comments are English.
+- No formatter/linter; `Scripts/verify.sh` is the guard — keep its greps in
+ sync when you change constants or symbols.
+
+## Testing
+
+- XCTest with `@testable import Luma`; one file per model/policy
+ (e.g., `ArchiveSyncPaginationTests.swift`), methods named `test...`.
+- Unit-test pure logic (policies, pagination, checkpoints); network/UI flows
+ are verified manually.
+- When a policy constant changes, update the matching `Tests/*` assertions and
+ the `Scripts/verify.sh` guard in the same commit.
+
+## Commit & Pull Request
+
+- Short, imperative, English subject lines (see `git log`: "mam fixes",
+ "fix errors and warnings").
+- CI (`.github/workflows/ios.yml`) runs `xcodebuild test` on push to
+ `main`/`develop` and on PRs to `main`; PRs must keep it green.
+- Update `README.md` / `Docs/*` whenever behaviour or architecture changes.
diff --git a/Config/Luma-Info.plist b/Config/Luma-Info.plist
index 0d31df7..8914c70 100644
--- a/Config/Luma-Info.plist
+++ b/Config/Luma-Info.plist
@@ -24,12 +24,16 @@
$(CURRENT_PROJECT_VERSION)
NSCameraUsageDescription
Luma использует камеру для фотографий, видеосообщений и видеозвонков.
+ NSFaceIDUsageDescription
+ Luma использует Face ID для разблокировки приложения.
NSLocalNetworkUsageDescription
Luma использует локальную сеть для прямого соединения аудио- и видеозвонков.
NSLocationWhenInUseUsageDescription
Luma использует геопозицию только когда вы выбираете отправку точки в чате.
NSMicrophoneUsageDescription
Luma использует микрофон для голосовых сообщений и звонков.
+ NSPhotoLibraryAddUsageDescription
+ Luma сохраняет фотографии и видео в вашу медиатеку.
UIApplicationSupportsIndirectInputEvents
UIBackgroundModes
@@ -40,6 +44,19 @@
UILaunchScreen
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
UISupportsDocumentBrowser
diff --git a/Config/LumaMac-Info.plist b/Config/LumaMac-Info.plist
index 8bc023e..3530fb8 100644
--- a/Config/LumaMac-Info.plist
+++ b/Config/LumaMac-Info.plist
@@ -26,6 +26,8 @@
public.app-category.social-networking
NSCameraUsageDescription
Luma использует камеру для фотографий, видеосообщений и видеозвонков.
+ NSFaceIDUsageDescription
+ Luma использует Touch ID для разблокировки приложения.
NSHighResolutionCapable
NSLocationUsageDescription
diff --git a/Config/LumaMac.entitlements b/Config/LumaMac.entitlements
index 5c62a47..1dbabeb 100644
--- a/Config/LumaMac.entitlements
+++ b/Config/LumaMac.entitlements
@@ -10,6 +10,8 @@
com.apple.security.files.user-selected.read-only
+ com.apple.security.files.user-selected.read-write
+
com.apple.security.device.camera
com.apple.security.device.audio-input
diff --git a/Config/LumaWatch-Info.plist b/Config/LumaWatch-Info.plist
index 613282f..09d9cc4 100644
--- a/Config/LumaWatch-Info.plist
+++ b/Config/LumaWatch-Info.plist
@@ -27,6 +27,6 @@
WKApplication
WKCompanionAppBundleIdentifier
- app.luma.chat
+ $(COMPANION_BUNDLE_IDENTIFIER)
diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md
index b127d78..212328a 100644
--- a/Docs/ARCHITECTURE.md
+++ b/Docs/ARCHITECTURE.md
@@ -9,7 +9,9 @@
3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks;
AES-GCM выполняется CryptoKit.
4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live
- сообщения и сохраняет snapshot локально.
+ сообщения и сохраняет их в per-account SwiftData-хранилище (`ArchiveStore`,
+ отдельный `ModelContainer` на аккаунт); список чатов, лента и список
+ получателей при пересылке читают данные напрямую через `@Query`.
5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS.
6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot,
текстовые ответы возвращаются немедленно или через гарантированную очередь,
@@ -35,11 +37,27 @@ XEP-0045-комнатами в отдельном разделе «Группо
собственных дополнительных устройств; при выключенном отправляется обычный
`
` внутри TLS-соединения. Ошибка OMEMO никогда не вызывает автоматический
fallback на plaintext. UI сразу показывает optimistic message и обновляет
-состояние по результату записи/receipt.
+состояние по результату записи/receipt; XEP-0333 ``-маркер
+собеседника переводит исходящее сообщение в состояние «прочитано».
+
+## OMEMO 2
+
+Помимо legacy `eu.siacs.conversations.axolotl` (MartinOMEMO) поддерживается
+`urn:xmpp:omemo:2` (XEP-0384 0.8.3) через собственный модуль
+`LumaOMEMO2Module`: payload — AES-256-CBC + HMAC-SHA-256 (HKDF, info
+«OMEMO Payload»), plaintext — SCE-envelope (XEP-0420); device list и bundle
+публикуются в PEP-узлах `urn:xmpp:omemo:2:devices` /
+`urn:xmpp:omemo:2:bundles`. Double Ratchet-сессии общие с legacy-модулем
+(тот же `LumaOMEMOStore`). Исходящие предпочитают OMEMO 2, когда у собеседника
+есть OMEMO 2-устройства (в группах — только если устройства есть у всех
+участников), иначе используется legacy. MartinOMEMO завендорен в
+`ThirdParty/` с точечными патчами видимости (см. `THIRD_PARTY_NOTICES.md`).
Входящая stanza может прийти напрямую, через carbons или MAM. Сервис определяет
peer, расшифровывает payload, использует `origin-id`/stanza id для дедупликации и
-передаёт value-type envelope в `AppModel`.
+передаёт value-type envelope в `AppModel`. Дедупликация выполняется по
+хеш-индексам (`origin-id`, `stanza-id`, `id`), поэтому стоимость применения пачки
+не растёт квадратично с размером архива.
Свежая установка начинает MAM с ограниченной последней страницы RSM, а не с
самого старого сообщения. Инкрементальные проходы используют сохранённый
@@ -48,14 +66,18 @@ peer, расшифровывает payload, использует `origin-id`/sta
Каждая stanza проходит проверку `queryid` и источника личного архива. Martin
публикует MAM-результаты на parser queue: ограниченный inbox принимает там всю
страницу и делает единственный hand-off на main actor после финального IQ.
-Дешифрование выполняется по одной stanza с уступкой event loop, а декодированные
-изменения становятся видимыми только одной атомарной пачкой вместе с новой
-контрольной точкой. Один foreground-проход имеет конечный бюджет страниц и
-времени; ошибка страницы не перезапускает весь архив сама. При уходе приложения
-с экрана и на время записи/отправки видеосообщения активный MAM-запрос
-закрывается, чтобы дешифрование истории не конкурировало с камерой и upload.
-Только после commit `AppModel` обновляет SwiftUI, локальный snapshot и Apple
-Watch; незавершённый проход при timeout/disconnect отбрасывается целиком.
+Расшифровка OMEMO выполняется на отдельной серийной фоновой очереди, поэтому
+Signal-криптография не блокирует главный поток; построение envelope и публикация
+остаются на main actor. Видимый индикатор «Синхронизация истории…» показывается
+только на первичной странице, а инкрементальная догрузка backlog продолжается в
+фоне молча, публикуя декодированные изменения атомарными пачками по мере
+готовности страниц. MUC-архив проходит тем же путём и флашит декодированные
+групповые сообщения по завершении catch-up комнаты. Ошибка отдельной страницы
+автоматически повторяется ограниченное число раз. При уходе приложения с экрана
+и на время записи/отправки видеосообщения активный MAM-запрос закрывается, чтобы
+дешифрование истории не конкурировало с камерой и upload. Только после commit
+`AppModel` обновляет SwiftUI, SwiftData-хранилище и Apple Watch; незавершённый
+проход при timeout/disconnect отбрасывается целиком.
Редактирование реализовано стандартным XEP-0308: исправление получает новый id и
`` с id исходного сообщения. `AppModel` проверяет совпадение bare JID
@@ -89,13 +111,19 @@ stanza отдельно сохраняет назначенный комнато
декодер считает payload дубликатом, поэтому последующие XEP-0461 ответы остаются
межклиентно совместимыми.
+Выход из комнаты оставляет чат в списке как «Не подключено». Удаление чата
+убирает его и локальную историю с устройства (и предварительно выходит из
+комнаты на сервере, если участник в ней); подавление на время сессии не даёт
+событиям комнаты создать удалённый чат заново.
+
XEP-0424 retraction проходит через тот же plaintext/OMEMO-путь, что и исходное
сообщение. Перед применением `AppModel` проверяет conversation и JID автора,
после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее
редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM,
временно удерживается в ограниченной очереди. Локальное удаление физически
-убирает запись из snapshot и сохраняет отдельный набор подавленных id, чтобы
-последующая MAM-синхронизация не добавила её обратно.
+удаляет строку из SwiftData-хранилища (при открытии хранилище также вычищает
+строки, помеченные удалёнными старыми сборками) и сохраняет отдельный набор
+подавленных id, чтобы последующая MAM-синхронизация не добавила её обратно.
Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не
переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и
@@ -182,6 +210,33 @@ trickle ICE candidates накапливаются до успешного нач
успешным и получает длительность. Удаление `activeCall` до закрытия Jingle-сессии
гарантирует, что ответный callback не создаст дубликат карточки.
+Карточка звонка синхронизируется между устройствами: по завершении вызова
+устройство отправляет самому себе (собственный bare JID) служебное сообщение с
+`` (направление, статус,
+длительность, время начала, собеседник, видео). Carbons доставляют его на
+остальные устройства, MAM сохраняет в архиве; получатели парсят payload и
+создают ту же карточку, а дедупликация идёт по origin-id, совпадающему с
+clientID карточки на исходном устройстве. Пропущенный входящий звонок
+увеличивает счётчик непрочитанных на каждом устройстве.
+
+Состояние «прочитано» (`.read`) синхронизируется между устройствами тем же
+каналом. Устройство, получившее от собеседника ``-маркер
+(XEP-0333) или, для MUC, маркер от участника комнаты, переводит исходящее
+сообщение в `.read` и отправляет собственному bare JID служебное сообщение с
+`` (чат, origin-id, stanza-id
+сообщения). Carbons доставляют его на остальные устройства, MAM архивирует —
+поэтому свежая установка и повторные прогоны архива сходятся к одному
+состоянию, даже когда маркер приходит раньше самого сообщения (MAM отдаёт
+станзы от новых к старым): маркер ждёт в `pendingReadMarkers` до upsert
+сообщения. Для комнат маркер адресован собственному bare JID и ссылается на
+stanza-id из MUC-MAM, так что состояние прочтения не попадает в архив
+комнаты. При открытии 1:1-чата Luma отправляет собеседнику `` для
+последнего входящего сообщения (в MUC маркеры не отправляются — XEP-0333).
+Слияние копий одного исходящего сообщения (live/MAM/MUC-MAM/Carbons)
+монотонно: `Delivery.merged(with:)` не позволяет архивному повтору с `.sent`
+понизить уже установленные `.delivered`/`.read`, а `.failed` остаётся
+неизменным до явной повторной отправки.
+
Текущая реализация принимает вызов при живом XMPP-соединении. Wake-up завершённого
iOS-приложения намеренно не имитируется: production-путь требует VoIP push,
PushKit/CallKit и серверной APNs-инфраструктуры.
@@ -199,6 +254,7 @@ PushKit/CallKit и серверной APNs-инфраструктуры.
- APNs provider + XEP-0357 registration;
- современный `urn:xmpp:omemo:2` adapter и миграция устройств;
- MIX и реакции;
-- SQLite/SQLCipher вместо JSON snapshot;
+- шифрование базы SQLCipher поверх SwiftData SQLite (история уже хранится
+ в SwiftData, а не в JSON snapshot);
- PushKit/CallKit для фоновых входящих звонков и групповые звонки;
- share extension и notification service extension.
diff --git a/Docs/PROSODY.md b/Docs/PROSODY.md
index 19f24ff..5db038f 100644
--- a/Docs/PROSODY.md
+++ b/Docs/PROSODY.md
@@ -105,18 +105,18 @@ participant читать member list. Для уже существующей к
Для `upload.example.org` нужен корректный HTTPS URL. Если компонент не является
прямым поддоменом VirtualHost, добавьте его в `disco_items`. При reverse proxy
задайте `http_host`/`http_external_url` согласно вашей топологии. Например, для
-`wthome.ru` за HTTPS reverse proxy:
+`example.com` за HTTPS reverse proxy:
```lua
-Component "upload.wthome.ru" "http_file_share"
+Component "upload.example.com" "http_file_share"
http_file_share_size_limit = 1024 * 1024 * 1024
- http_host = "upload.wthome.ru"
- http_external_url = "https://upload.wthome.ru/"
+ http_host = "upload.example.com"
+ http_external_url = "https://upload.example.com/"
trusted_proxies = { "127.0.0.1", "::1" }
modules_disabled = { "s2s" }
```
-Reverse proxy должен передавать `Host: upload.wthome.ru` и
+Reverse proxy должен передавать `Host: upload.example.com` и
`X-Forwarded-Proto: https`. Иначе Prosody может выдать клиенту `http://` URL или
маршрут, возвращающий 404; Luma намеренно не понижает upload до HTTP.
@@ -138,7 +138,7 @@ Reverse proxy должен передавать `Host: upload.wthome.ru` и
prosodyctl check config
prosodyctl check certs
prosodyctl shell module info http_file_share
-prosodyctl shell http list upload.wthome.ru
+prosodyctl shell http list upload.example.com
```
Начиная с 0.3.0 Luma различает ошибки discovery, получения upload slot,
diff --git a/Docs/SECURITY.md b/Docs/SECURITY.md
index 22bfb87..1996fcb 100644
--- a/Docs/SECURITY.md
+++ b/Docs/SECURITY.md
@@ -1,12 +1,39 @@
# Безопасность и модель угроз MVP
- Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или
- snapshot истории.
+ локальное хранилище истории.
- Проверка сертификата не отключается. Ручной host меняет маршрут подключения,
но XMPP domain/JID остаётся исходной идентичностью.
- TLS trust оценивается системным Apple Security framework как сертификат
сервера (`SecPolicyCreateSSL(true, ...)`) для домена из JID; hostname и цепочка
доверия обязательны даже при ручном адресе подключения.
+- Приложение можно заблокировать паролем (хранится в Keychain, выключено по
+ умолчанию): блокировка срабатывает при запуске и при уходе в фон, а
+ разблокировка возможна по Face ID/Touch ID.
+- Пароль для SCRAM нормализуется по RFC 4013 (SASLprep), чтобы совпадать с
+ серверной нормализацией; для PLAIN пароль отправляется как введён.
+- SCRAM доступен в вариантах SHA-512, SHA-256 и SHA-1 (SHA-512 —
+ реализация Luma), а также в PLUS-вариантах с канальной привязкой
+ (SCRAM-SHA-512-PLUS > SHA-256-PLUS > SHA-1-PLUS): gs2-заголовок
+ «p=tls-exporter,» (RFC 9266) или «p=tls-server-end-point,» доказывает,
+ что SASL-обмен идёт по тому же TLS-каналу, который видит клиент, и
+ ломает MITM-ретрансляцию. PLUS выбирается только когда сервер
+ рекламирует соответствующий channel-binding тип, иначе — обычный SCRAM;
+ механизм выбирается по рекламе сервера.
+- OMEMO работает в legacy-неймспейсе `eu.siacs.conversations.axolotl` (так
+ умеет закреплённая версия MartinOMEMO) и в OMEMO 2 (`urn:xmpp:omemo:2`,
+ payload AES-256-CBC + HMAC-SHA-256 в конверте SCE `urn:xmpp:sce:0`). Luma
+ публикует omemo:2 bundle и список устройств, делит Double Ratchet-сессии с
+ legacy-модулем и помечает протокол каждого ключа на экране шифрования.
+- Канал шифруется STARTTLS/direct TLS собственным TLS-процессором Luma на
+ OpenSSL 3 (SecureTransport ограничивает потоковый TLS версией 1.2 и не
+ имеет keying exporter): минимум TLS 1.2, максимум TLS 1.3, ALPN
+ «xmpp-client», SNI по домену XMPP. Сертификат проверяется через
+ SecTrust с серверной политикой домена из JID (та же логика
+ CertificateTrustEvaluator). Фактическая версия TLS, шифр и выбранный
+ SASL-механизм (самый стойкий из рекламируемых сервером, с канальной
+ привязкой при её доступности) показываются на экране информации о
+ сервере.
- Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения
не откатываются на plaintext при ошибке: ошибка показывается пользователю.
- Пользователь может осознанно отключить OMEMO глобально или для отдельного
@@ -49,22 +76,34 @@
STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже
зашифрованный медиапоток. Если Prosody не публикует собственный сервис через
XEP-0215, резервный публичный STUN получает запрос на определение адреса.
-- На iOS файлы OMEMO-состояния и локальной истории получают data-protection
- `completeUntilFirstUserAuthentication`.
+- На iOS файл OMEMO-состояния и база локальной истории (SwiftData
+ `.store`) получают data-protection `completeUntilFirstUserAuthentication`.
+
+- Архивные эхо собственных служебных станз (receipts, chat markers,
+ повторно доставленные копии) и собственные сообщения, которые невозможно
+ расшифровать обратно (нет encrypt-to-self ключа или self-сессия устарела),
+ не превращаются в пузыри «Не удалось расшифровать сообщение»: они
+ отбрасываются, как это делают другие клиенты, чтобы не засорять историю
+ фантомами, которых нет в виде сообщений на сервере.
+- Сохранение медиа выполняется только по явному действию пользователя:
+ фото/видео/видеосообщения — в медиатеку, файлы и голосовые — в «Файлы»
+ (Загрузки). Расшифрованное содержимое при этом покидает sandbox приложения.
## Известные ограничения
-- На macOS история и OMEMO state лежат в sandbox Application Support без
- дополнительного SQLCipher-слоя; защиту обеспечивает учётная запись и FileVault
- пользователя. Для повышенной модели угроз нужна зашифрованная база.
+- На macOS история (SwiftData SQLite в Application Support) и OMEMO state
+ лежат в sandbox без дополнительного SQLCipher-слоя; защиту обеспечивает
+ учётная запись и FileVault пользователя. Для повышенной модели угроз нужна
+ зашифрованная база.
- Локальная история содержит уже расшифрованные тексты, как и у большинства
клиентов. Полное удаление требует выхода с опцией удаления истории.
-- «Удалить у меня» удаляет запись только из локального snapshot этого устройства
- и запоминает id против повторной MAM-загрузки; оно не удаляет серверный архив,
- другие собственные устройства или копию собеседника.
+- «Удалить у меня» удаляет запись только из локального SwiftData-хранилища
+ этого устройства и запоминает id против повторной MAM-загрузки; оно не
+ удаляет серверный архив, другие собственные устройства или копию собеседника.
- Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого
- JID, а переопределение чата — в локальном snapshot. Эти значения не являются
- секретами, но прямо влияют на конфиденциальность следующей отправки.
+ JID, а переопределение чата — в локальном SwiftData-хранилище. Эти значения
+ не являются секретами, но прямо влияют на конфиденциальность следующей
+ отправки.
- Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется
настройками PEP и roster на сервере; не используйте аватар как секретное фото.
- Используемая реализация MartinOMEMO поддерживает распространённый legacy
diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj
index 54a358e..7eb0ca2 100644
--- a/Luma.xcodeproj/project.pbxproj
+++ b/Luma.xcodeproj/project.pbxproj
@@ -9,141 +9,206 @@
/* Begin PBXBuildFile section */
00FCAB7210D005D2C30747CB /* AccountConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */; };
01039E013AB84FA338B2A5CF /* MessageCorrectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 096748EF4DC92889BCAB3260 /* MessageCorrectionTests.swift */; };
+ 01E4131ED4DBED0BEAD465A2 /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */; };
01EBE88C86DB1C4745D1AB08 /* VideoNoteRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */; };
03629CA43EB6D367498E90E8 /* VideoNotePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */; };
03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; };
03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; };
0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */; };
+ 054AED20075FF49401C3E59C /* VideoNoteRotationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */; };
+ 059A5169D66AD8F04B341403 /* LumaSaslChallengeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */; };
067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; };
- 08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AA66547A2AA2D0979DA76E /* MainChatView.swift */; };
087E600676B3F6134BB26112 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.swift */; };
088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; };
097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */; };
09D1784380F717ED92BEF000 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A97545E6E481D45E0BB20F /* ChatView.swift */; };
+ 0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */; };
0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */ = {isa = PBXBuildFile; fileRef = C86EBBD15843C6FB110C798E /* MediaFileIO.swift */; };
0ECAB3E52DD12AD78AABE5F5 /* StaticDNSSrvResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */; };
10273AF92A952B707C58AEE6 /* ArchiveSyncWorkBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */; };
10C5382981A87A2963DD397C /* MediaMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */; };
+ 120BD1C8A163015AF27FFA42 /* SASLMechanismPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */; };
+ 127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */; };
12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */; };
137816B417A865C97F935A00 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = EFCAF9B0DE67466AF22E76C3 /* WebRTC */; };
+ 14059C4EF59FBBDCC5338420 /* AppLockPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47680B7C615374A8C245818F /* AppLockPolicy.swift */; };
1655A2B7ACF4003EF41A1AB8 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; };
18606BEB0A2ABB7FDD995645 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */; };
18627D523DEC6B1A3064906D /* AttachmentPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */; };
1AC03E6E9EA1D0C1F59F7619 /* ArchiveSyncPagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */; };
1B20F2D3E4F18E6094B103BF /* LumaOMEMOStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */; };
+ 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */; };
+ 1BD08940BB8E5DE4EDB2AF30 /* ReadStateSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */; };
1BD84AA796838F48F847D192 /* AudioMessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */; };
+ 1C78234B3FA4E528C7F701B7 /* LumaOMEMO2Module.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */; };
+ 1C948E97A14DBA90C24A68FF /* SwiftChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */; };
1CB1DAF27B8632B2E071338E /* VideoNoteRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */; };
1D5B3487BDC63B051A33D874 /* VideoNoteRecordingLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */; };
1E239B9338CEEA7EA704626C /* ChatTimelineEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */; };
+ 1F467DB76CE93C90F09E2991 /* Shared.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7C9866558B72CB1909A01EEC /* Shared.xcassets */; };
1FE98743A1FB61B4E9C86696 /* AttachmentPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */; };
203D6124DF78461D8E86FDA8 /* AvatarImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */; };
21AAF3FB5846B1B54BBEE7ED /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F36BB344D0FDF23127DF069 /* AppModel.swift */; };
230E0DAA5396EA507D6F6D47 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */; };
235ED577A9FB1D2A97A4749F /* LumaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0A53D1B25A1923ADF96C35 /* LumaWatchApp.swift */; };
23AE47B738853110F72C0580 /* Martin in Frameworks */ = {isa = PBXBuildFile; productRef = 4BD4324EE01A6DD46EBE7FDA /* Martin */; };
+ 24E3C4EEC4EB5116982D2F4E /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = 01363A8EB6E9A3C97C1B316D /* OpenSSL */; };
25A9ADB0FF1496A4D3EF431D /* ForwardMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81213335D275609C38056220 /* ForwardMessageView.swift */; };
275501728E0597A8E53F1D4D /* VideoNoteRecordingCompletionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */; };
2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; };
28D758624032D813C607DE5B /* MediaSendActivityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */; };
+ 2987B54F6E358AAE2EE19993 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; };
+ 2A728A647EDD4F098802C64C /* TLSVersionProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */; };
+ 2A7611650AB52EDABB978362 /* ChatBubbleShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */; };
+ 2BC210A585AAA9D50ECC9A4B /* AppLockPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47680B7C615374A8C245818F /* AppLockPolicy.swift */; };
2C5BAAEF4EC7AAE5FE0FEEDB /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; };
2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; };
+ 2D32E57F5CE1BF2EAE078492 /* DeviceResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876CDF98063A4C948B00C7CE /* DeviceResource.swift */; };
2D83457A8857592E23289EF7 /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; };
+ 2F175DFA6D0FBF31B7EED21F /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; };
2F44B4FDA9AF67EB24776A16 /* WatchViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */; };
30BE19F459FDDAD640F66C68 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; };
3121293D13481C39489CBC8F /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; };
+ 312B8D266C5BEC4F7B2F3276 /* MediaPreviewProcessorVideoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */; };
3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; };
32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */; };
32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; };
+ 32E78EE5035DD08181B50B86 /* VideoNoteRotationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */; };
3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; };
+ 33EF454D77366A0E9137D824 /* MessageBubbleEntrance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1180AA3C5AC03BB48393D97 /* MessageBubbleEntrance.swift */; };
+ 39C3E7AC663AB6C8DACFFAA8 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = A99B4C1B1459E1C34F214164 /* OpenSSL */; };
+ 3BA375F1E0934FB4DD2148F9 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; };
3BEF3895B4C84880B9103058 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A4CE3E678096BA76F2988C /* NewChatView.swift */; };
+ 3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */; };
3C2727D165D2EF1CC742EEF7 /* VideoNoteCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */; };
3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; };
+ 3D3DDA2292673CE4FF397174 /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */; };
3D67F14421F89B6DA4400642 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05EEEE8A125347F7C5014A09 /* LoginView.swift */; };
3D74BDDE2D978A2CC795E33A /* CertificateTrustEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */; };
+ 3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */; };
3E3A82A737D9009D9BDC04E0 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515DD6F1F80A848C39EBAE84 /* RootView.swift */; };
3F1571511219DBA18F67209F /* ChatTypingPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */; };
+ 411136B21632A796E26B207C /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */; };
433A7B65E635FB59021F7D87 /* ChatTypingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E51BB890F05FD02C71F6E /* ChatTypingPolicy.swift */; };
44AF85896EE023775B91DE8B /* VideoNotePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */; };
46924A3275BFEFD2218AD748 /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; };
46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */; };
481F1C57B07AF615A3629C7E /* ArchiveSyncRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */; };
+ 48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */; };
48BE02EB8AA84E9CD1354E1A /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; };
+ 48C34A0CBF309A652AE350B3 /* VideoNoteRotationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */; };
496D99C625D771D6B91D99E7 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; };
49DC659401787CF44A33B620 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; };
4A3844CC51129576741753E7 /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; };
4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */; };
+ 4B65093C19D148E04A675440 /* ChatMessageEntrancePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B311D6C0234E1C910457FCE0 /* ChatMessageEntrancePolicy.swift */; };
4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; };
+ 4DE3B27CFA3D524590FB751A /* AppLockPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */; };
+ 4E1B116771922F92FEEB079B /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; };
4EFADFE085B42E0B0593E501 /* ArchiveSyncCheckpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */; };
51AE33BE56ED4543014119D0 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; };
+ 523D54621DD4DDD54EA8D510 /* MediaDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */; };
+ 52CA5DEE0CF7697D1B2D1FEA /* MessageBubbleEntrance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1180AA3C5AC03BB48393D97 /* MessageBubbleEntrance.swift */; };
52F75F60459DA50E1815479A /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; };
5409F5C90FBE0817F4AC8176 /* ChatTimelineEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */; };
5415AF453AB8FCDA55FB59AD /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; };
+ 5459CF1808474747AF46A366 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; };
5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; };
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; };
- 5FB4DEEA5321D80CCD63B864 /* ChatArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */; };
+ 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */; };
+ 5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686F6A6EB3740DE94310653B /* PreviewSupport.swift */; };
+ 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; };
+ 6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; };
60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; };
6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; };
61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */; };
6216C6AA3787BBF776D78E91 /* PhoneWatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */; };
640C30ABB02E6486E6B7665E /* VideoNoteCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */; };
64C8B9876278B711CC6479E0 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; };
+ 64C9A8E42046AC0FCB701613 /* TLSVersionProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */; };
64F4AD83D86B8900DE69F117 /* CredentialVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE381301A55B1C4F333B523 /* CredentialVault.swift */; };
+ 65D2B572D508EF10931420FD /* ChatMessageEntrancePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B311D6C0234E1C910457FCE0 /* ChatMessageEntrancePolicy.swift */; };
+ 662B8E72F00F5A77DE28819A /* SCRAMPlusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */; };
66489D4D15366683D3095B3D /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */; };
66613FF7E056788222552D6C /* NotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */; };
+ 670970182A27A26B8E44E687 /* LumaTLSNetworkProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */; };
67143F44FF20A87E8875CFFB /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; };
67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; };
+ 6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686F6A6EB3740DE94310653B /* PreviewSupport.swift */; };
+ 6950D2E35897C3A6C537AE08 /* CallHistorySyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */; };
6AA0AC6E8E25E8A85D63E7CC /* WatchSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A5787450609B0C14093679 /* WatchSessionModel.swift */; };
6AB86E72201EB1A61898AD5F /* InlineVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */; };
6B0B8F3CAC1E5854C6F81E70 /* AccountConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */; };
6B311EF7CF11B52E75D929F0 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = 2A745A82DD6F34F0CB46B52B /* WebRTC */; };
- 6BB061F609EF40334C1DE979 /* LumaWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 19D4E9404712E8643E31CB93 /* LumaWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
6DC36AA33268FC6457FADAE2 /* BubbleShapes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */; };
6FB79BEEEF151D297F4EBB33 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.swift */; };
6FCF6A4EFF8AE1FBFC2D81E1 /* ChatMediaImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */; };
+ 7227469C6E25E0F4FF7FC805 /* Shared.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7C9866558B72CB1909A01EEC /* Shared.xcassets */; };
7306BA14F94818248594AF91 /* WatchVoiceMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */; };
73A21AFC42D6C00E6C2C5082 /* CertificateTrustEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */; };
7425493304D731FA218E9151 /* ArchiveSyncPaginationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */; };
7809154E961CC8EFE7EE852F /* OMEMODevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */; };
79169D5FE931EBE0D0801516 /* ArchiveSyncPagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */; };
+ 791AB9983E2146DBF1D7B606 /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */; };
7A469F326228FB660739D1C2 /* ConnectionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */; };
7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */; };
+ 7B3DD63332C8149FAE907B0E /* ChatMessageDeliveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */; };
7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; };
+ 7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; };
+ 7D095DA2AAD5E7D813586E7D /* LumaSaslChallengeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */; };
7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */; };
- 7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AA66547A2AA2D0979DA76E /* MainChatView.swift */; };
7F012252FA73A8282B92323C /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; };
7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; };
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; };
7F7BA6805C1D1EBCEAFDD3AD /* ChatTimelineEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */; };
+ 7FBD6C44C2CA9D50E28E823A /* AppLockVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */; };
+ 81091B6BA7D793CA233812ED /* CallHistorySync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */; };
814FE6C4CB53207F236E9236 /* LocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2786A15415F65305F78D892C /* LocationPickerView.swift */; };
822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */; };
8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81213335D275609C38056220 /* ForwardMessageView.swift */; };
86D39FA124355A8B53903164 /* Martin in Frameworks */ = {isa = PBXBuildFile; productRef = 6EDEC6EEE315B77491214398 /* Martin */; };
8714A5A67956D6A78D4D3E4B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */; };
+ 8831E0A88F3759766F9BCC34 /* CallHistorySync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */; };
89712B216A98861B5D994587 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; };
89E2B0373E2DA2B25863A866 /* VideoNoteStopPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */; };
8B98B818AC4379F03907B0E0 /* ChatScrollPositionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */; };
8DE9617B5B0CBCA875C299F9 /* WatchVoiceRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */; };
902C6AD49443720ED4E4E43C /* ComposerRecordingGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */; };
9089C00D2A0766A75B1EB93D /* CryptoKitAESGCMEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */; };
+ 920CB33C235AF4BDB4107BB7 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; };
9393464C4C3BE5F9546FE8BB /* EncryptionPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */; };
+ 939ABD4DED652588957491CF /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; };
+ 945014504AA02172175B48D2 /* SwiftChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */; };
94FB3597E01FC508F872861E /* MessageReplySwipePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */; };
958FD3CBEC600651E1171B55 /* MediaPickerSelectionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */; };
+ 97A3A0635ADA8A8227C94216 /* ChatBubbleShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */; };
992195D6AE64DF2C24A52956 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 371134B6F3E284082E28FE08 /* MartinOMEMO */; };
+ 9AB2A519206C5016B0D8C6DE /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; };
+ 9B53D50C5EDE4AD507C3F5B6 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; };
9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; };
+ 9D2352A11D04AB0F2DDBD368 /* ReadStateSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */; };
+ 9E36AF2F40D4EC3AF5CE1680 /* LayoutCheckUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */; };
9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */; };
9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */; };
+ 9F288ACC92CD62A2E0CCE157 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; };
A07ABE478F7F48C3FEDC22D5 /* EncryptionPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */; };
A1D32581BA70F25420F0A322 /* ArchiveSyncWorkBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D15DA1B85B68AD2419670C8 /* ArchiveSyncWorkBudget.swift */; };
A29C828AC4B571941BF0B8E9 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */; };
A2AF2B19A1E9D9B8D6E1CD63 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */; };
+ A4A742F3BBEFCAFD29975968 /* SaslFailureMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */; };
A69C1CD6CC1FF13631044BA3 /* GeoLocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */; };
+ A6B05CBBA8A2B4C33B41F47D /* OlderHistoryScrollPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */; };
+ A73E17CEC3F4E8624102FC17 /* OlderHistoryScrollPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */; };
+ A87AB7D40BD589F5EBCCBD6F /* LumaOMEMO2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4170740AD7BA5D0EC8C870F0 /* LumaOMEMO2Tests.swift */; };
A90142509385DE6E83818953 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */; };
ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515DD6F1F80A848C39EBAE84 /* RootView.swift */; };
AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */; };
ADB2CCC69CA91FA820B3A4CA /* MessageReactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD92CDC8D3E0DA178D0E3F52 /* MessageReactionTests.swift */; };
- AEC21F39F40AFBD3B4225B3C /* ChatArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */; };
+ AF1AE686F7B4915C4636099D /* OlderHistoryScrollPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */; };
+ AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; };
AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; };
B0C6096A8D8F1EAD0B4154B7 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */; };
+ B13513FE5DAD462086B8B0B0 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; };
B33870CD89289E44882750C3 /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */; };
B429AC460AF49367463F59A8 /* VideoNoteRecordingCompletionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */; };
B492852FD89322434B0914AE /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F36BB344D0FDF23127DF069 /* AppModel.swift */; };
@@ -155,17 +220,23 @@
BAA3A9846822ED9F9E5C7111 /* ConnectionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */; };
BAF755D5DBE713646B4CE5AF /* AudioMessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */; };
BE4D5853DAC0D44A6132D800 /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; };
+ BEF48E72571CF850AAA7F17F /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */; };
+ C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */; };
+ C23C74AEDA88885A209C9BB1 /* AppLockVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */; };
C2CC9BABAE68FA9258694766 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; };
- C557E56AE73731A5BC2FD327 /* ChatArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */; };
C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; };
C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */; };
C824389F95739F79F3E075C8 /* MessageInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C97DDD3DA8B45C3F2085661 /* MessageInteractionTests.swift */; };
C92B463DCD052A3215CB7099 /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7846C2EEE2551C6690A4FDC6 /* Conversation.swift */; };
C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */; };
+ CA39FA78E8BFE26BEBF976E5 /* MediaDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */; };
CB66B3FCB7036E9AE50B6BF3 /* VideoNoteRecordingLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */; };
CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; };
CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; };
+ CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; };
CC5297F90A3DBD45C5E41A3A /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */; };
+ D0EB41F8E01D4B654254E101 /* ReadStateSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */; };
+ D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; };
D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5B4C1116F6ACB6F9B64BF4 /* NotificationCoordinator.swift */; };
D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; };
D3777F3C0A81F08A9983048B /* StaticDNSSrvResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */; };
@@ -173,22 +244,30 @@
D551F2AA5DCCCCDD8D8FB9BB /* AccountConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */; };
D5E6B1859101ABBD33D90C3C /* ChatScrollPositionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */; };
D63F32533880B0D715ECFC79 /* NotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */; };
+ D6718F8B1359CD06F44A91A8 /* SASLMechanismPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */; };
D6D0A798487AE98AE03A579C /* NewGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99BE34DC082D19007241AA62 /* NewGroupView.swift */; };
D6ED224CC13439A99A388676 /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; };
+ D7179E7F8127A702FC58D3D4 /* DeviceResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876CDF98063A4C948B00C7CE /* DeviceResource.swift */; };
D7CB4481F4F3B81D2C9EF5BD /* NewGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99BE34DC082D19007241AA62 /* NewGroupView.swift */; };
D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */; };
DA77E8A49D2D091EC0975DD3 /* InlineVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */; };
DBB0216BFAB04C3847FEE95C /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; };
DC4FAE63AB8AF89C30512763 /* MediaFileIOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 772A4F24CAF527E328657749 /* MediaFileIOTests.swift */; };
+ DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */; };
E2245416CB055ECEF0C5F81D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; };
E42A674072CE5D3AF1F5848D /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7846C2EEE2551C6690A4FDC6 /* Conversation.swift */; };
+ E449F2B17E6D5053EBA7F6A8 /* Shared.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7C9866558B72CB1909A01EEC /* Shared.xcassets */; };
+ E4B1FDFFD3BFFC47B5F154DE /* LumaOMEMO2Module.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */; };
E582A0424A63CA16DDA4E984 /* CryptoKitAESGCMEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */; };
E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */; };
E67E0D2C3FBA2C3739B692E0 /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; };
+ E6890C743C5BBD0B287DDB53 /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; };
+ E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */; };
E9FA22C965454FA6890DC3D5 /* AvatarImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */; };
EBAF4861DD93FBEAA4B37D1C /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; };
ECAC4761F04316A71EC3C716 /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; };
ECCD95DD4E16E0B393C707A9 /* MessageReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D414B62940A9234C51C3D14E /* MessageReaction.swift */; };
+ ED1C54A2FB844226AA284CCC /* LumaScramPlusMechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */; };
ED3869D5658BAA59F55B1E3D /* ArchiveSyncCheckpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */; };
EE2BF07D86C00AB6358EA5AE /* CredentialVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE381301A55B1C4F333B523 /* CredentialVault.swift */; };
EE4DFDBA97562C53C53FBF51 /* CryptoKitAESGCMEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */; };
@@ -196,30 +275,36 @@
F057C0C84B541A5564D0E7E7 /* ArchiveMessageBatchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */; };
F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; };
F27ACACEF60AFAB5AF96EF98 /* MediaTimeFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */; };
+ F320BBE2BE143D15398C46F9 /* ArchiveStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */; };
+ F5121F29EF3BEFD4FD54DD12 /* LumaTLSNetworkProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */; };
F558F58835D6E887DBF09EA0 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A97545E6E481D45E0BB20F /* ChatView.swift */; };
F80493F220991FF8A7CFF681 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05EEEE8A125347F7C5014A09 /* LoginView.swift */; };
+ F80767EC81E8CAFAA280DD6A /* SaslFailureMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */; };
+ F881ABC5077A3330B29C0DE6 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; };
F8B9CBE6849C45F828BE3BD4 /* XMPPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE298382FC70ECF1513BD4A /* XMPPService.swift */; };
F9BEB6552D0407A160501369 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A4CE3E678096BA76F2988C /* NewChatView.swift */; };
FB1052104B90994CF03FE6A2 /* LumaOMEMOStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */; };
FC5986CE50C84FA77983767C /* BubbleShapes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */; };
FC60F19740E3D0C75205DB61 /* ArchiveSyncWorkBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D15DA1B85B68AD2419670C8 /* ArchiveSyncWorkBudget.swift */; };
+ FCC5133038CDBEC39B5B42D3 /* LumaScramPlusMechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */; };
FCD330D04ED5B87E28883E58 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; };
FD15BE18C54AE2693518CB4A /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; };
FD9F8640841236BB78563573 /* MessageReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D414B62940A9234C51C3D14E /* MessageReaction.swift */; };
+ FDB78F0A58D66E36B9E25A47 /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */; };
FE958AD8FF4A43DB9A887882 /* XMPPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE298382FC70ECF1513BD4A /* XMPPService.swift */; };
FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */; };
FF07DC53E2877BFDE726132C /* PhoneWatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
- 0722750D38A3B60127F87761 /* PBXContainerItemProxy */ = {
+ 277A193FE4C68A777257EF0D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AACE5AD3943208FFB6B19277 /* Project object */;
proxyType = 1;
- remoteGlobalIDString = BAB78463D7DE07A8F7B3D27D;
- remoteInfo = LumaWatch;
+ remoteGlobalIDString = 0565F6C7DA26F0482928704E;
+ remoteInfo = Luma;
};
- 277A193FE4C68A777257EF0D /* PBXContainerItemProxy */ = {
+ 5C6218BA484572FDB4727ECA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AACE5AD3943208FFB6B19277 /* Project object */;
proxyType = 1;
@@ -228,34 +313,24 @@
};
/* End PBXContainerItemProxy section */
-/* Begin PBXCopyFilesBuildPhase section */
- 8C792A6B9B9150263A7D975B /* Embed Watch Content */ = {
- isa = PBXCopyFilesBuildPhase;
- buildActionMask = 2147483647;
- dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
- dstSubfolderSpec = 16;
- files = (
- 6BB061F609EF40334C1DE979 /* LumaWatch.app in Embed Watch Content */,
- );
- name = "Embed Watch Content";
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXCopyFilesBuildPhase section */
-
/* Begin PBXFileReference section */
0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteCaptureView.swift; sourceTree = ""; };
0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleShapes.swift; sourceTree = ""; };
03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubble.swift; sourceTree = ""; };
+ 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaScramPlusMechanism.swift; sourceTree = ""; };
05EEEE8A125347F7C5014A09 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; };
096748EF4DC92889BCAB3260 /* MessageCorrectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCorrectionTests.swift; sourceTree = ""; };
+ 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineUITests.swift; sourceTree = ""; };
0D15DA1B85B68AD2419670C8 /* ArchiveSyncWorkBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncWorkBudget.swift; sourceTree = ""; };
0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicyTests.swift; sourceTree = ""; };
0DE298382FC70ECF1513BD4A /* XMPPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMPPService.swift; sourceTree = ""; };
- 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchive.swift; sourceTree = ""; };
101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfigurationTests.swift; sourceTree = ""; };
12C6A6B83DA076867481CCF3 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; };
13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntry.swift; sourceTree = ""; };
+ 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLprepTests.swift; sourceTree = ""; };
+ 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = AppAssets.xcassets; sourceTree = ""; };
185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionDevicesView.swift; sourceTree = ""; };
+ 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSVersionProbe.swift; sourceTree = ""; };
19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyThreadOverlay.swift; sourceTree = ""; };
19D4E9404712E8643E31CB93 /* LumaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngine.swift; sourceTree = ""; };
@@ -263,7 +338,9 @@
1C3E51BB890F05FD02C71F6E /* ChatTypingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypingPolicy.swift; sourceTree = ""; };
1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationProvider.swift; sourceTree = ""; };
1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceMessageTests.swift; sourceTree = ""; };
+ 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessageTests.swift; sourceTree = ""; };
2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfiguration.swift; sourceTree = ""; };
+ 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMPlusTests.swift; sourceTree = ""; };
22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncWorkBudgetTests.swift; sourceTree = ""; };
22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPagination.swift; sourceTree = ""; };
235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPreferences.swift; sourceTree = ""; };
@@ -274,30 +351,49 @@
2786A15415F65305F78D892C /* LocationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPickerView.swift; sourceTree = ""; };
278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadata.swift; sourceTree = ""; };
28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicy.swift; sourceTree = ""; };
+ 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerView.swift; sourceTree = ""; };
2F36BB344D0FDF23127DF069 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; };
32DBAB8D25603892C7D67A23 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = ""; };
33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteStopPolicyTests.swift; sourceTree = ""; };
375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshotTests.swift; sourceTree = ""; };
37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoLocationTests.swift; sourceTree = ""; };
+ 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLMechanismPreferenceTests.swift; sourceTree = ""; };
+ 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = ""; };
+ 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStore.swift; sourceTree = ""; };
3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessor.swift; sourceTree = ""; };
3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicy.swift; sourceTree = ""; };
+ 4170740AD7BA5D0EC8C870F0 /* LumaOMEMO2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaOMEMO2Tests.swift; sourceTree = ""; };
4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = ""; };
43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; };
+ 47680B7C615374A8C245818F /* AppLockPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicy.swift; sourceTree = ""; };
+ 4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRotationPolicyTests.swift; sourceTree = ""; };
+ 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMDowngradeProtectionTests.swift; sourceTree = ""; };
5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = ""; };
+ 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = ""; };
515DD6F1F80A848C39EBAE84 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; };
5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = ""; };
+ 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaTLSNetworkProcessor.swift; sourceTree = ""; };
5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = ""; };
+ 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OlderHistoryScrollPolicy.swift; sourceTree = ""; };
5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecorder.swift; sourceTree = ""; };
+ 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMetadataRecord.swift; sourceTree = ""; };
5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreference.swift; sourceTree = ""; };
6079CE75BAB995515A8D5460 /* CallSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshot.swift; sourceTree = ""; };
60A97545E6E481D45E0BB20F /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; };
61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicyTests.swift; sourceTree = ""; };
- 6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySync.swift; sourceTree = ""; };
+ 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutCheckUITests.swift; sourceTree = ""; };
6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConversationTests.swift; sourceTree = ""; };
+ 686F6A6EB3740DE94310653B /* PreviewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewSupport.swift; sourceTree = ""; };
+ 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessage.swift; sourceTree = ""; };
+ 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaOMEMO2Module.swift; sourceTree = ""; };
6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerDismissGestureTests.swift; sourceTree = ""; };
6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReplyFallback.swift; sourceTree = ""; };
6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicy.swift; sourceTree = ""; };
6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; };
+ 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockVault.swift; sourceTree = ""; };
+ 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLMechanismPreference.swift; sourceTree = ""; };
72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaCallEngine.swift; sourceTree = ""; };
758B448979C845F56168A51F /* ConversationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationRow.swift; sourceTree = ""; };
76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTimeFormatterTests.swift; sourceTree = ""; };
@@ -306,26 +402,40 @@
7846C2EEE2551C6690A4FDC6 /* Conversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = ""; };
78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewView.swift; sourceTree = ""; };
7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessageRecorder.swift; sourceTree = ""; };
+ 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaScramSha512Mechanism.swift; sourceTree = ""; };
7C97DDD3DA8B45C3F2085661 /* MessageInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInteractionTests.swift; sourceTree = ""; };
+ 7C9866558B72CB1909A01EEC /* Shared.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Shared.xcassets; sourceTree = ""; };
7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerRecordingGestureTests.swift; sourceTree = ""; };
81213335D275609C38056220 /* ForwardMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForwardMessageView.swift; sourceTree = ""; };
83A5787450609B0C14093679 /* WatchSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionModel.swift; sourceTree = ""; };
+ 876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = ""; };
8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = ""; };
+ 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessorVideoTests.swift; sourceTree = ""; };
+ 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaConnectionStatsModule.swift; sourceTree = ""; };
+ 96720381802141EBA1DA9772 /* LumaUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = ""; };
99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = ""; };
+ 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; };
9A338A8EB30206647BDD922F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
9B11FED2D58621C071C3AD14 /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; };
9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicyTests.swift; sourceTree = ""; };
9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReplySwipePolicy.swift; sourceTree = ""; };
+ 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleShape.swift; sourceTree = ""; };
A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneWatchBridge.swift; sourceTree = ""; };
A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadataTests.swift; sourceTree = ""; };
A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OMEMODevice.swift; sourceTree = ""; };
A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMediaImageCache.swift; sourceTree = ""; };
A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSendActivityTracker.swift; sourceTree = ""; };
+ A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OlderHistoryScrollPolicyTests.swift; sourceTree = ""; };
A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPaginationTests.swift; sourceTree = ""; };
A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPreview.swift; sourceTree = ""; };
A8C051C2A7014E8B5E825477 /* LumaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaApp.swift; sourceTree = ""; };
+ AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDownloadService.swift; sourceTree = ""; };
+ ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStoreTests.swift; sourceTree = ""; };
B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicy.swift; sourceTree = ""; };
+ B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLprep.swift; sourceTree = ""; };
+ B2DC8B629E8D4F078B192FAB /* MartinOMEMO */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MartinOMEMO; path = ThirdParty/MartinOMEMO; sourceTree = SOURCE_ROOT; };
+ B311D6C0234E1C910457FCE0 /* ChatMessageEntrancePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageEntrancePolicy.swift; sourceTree = ""; };
B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicyTests.swift; sourceTree = ""; };
B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerItem.swift; sourceTree = ""; };
B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationMessagePreview.swift; sourceTree = ""; };
@@ -336,33 +446,45 @@
BF0A53D1B25A1923ADF96C35 /* LumaWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaWatchApp.swift; sourceTree = ""; };
C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDraft.swift; sourceTree = ""; };
C0590E38A89EC4CC78791C22 /* CallSDPOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSDPOrderingTests.swift; sourceTree = ""; };
+ C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateSyncTests.swift; sourceTree = ""; };
C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypingPolicyTests.swift; sourceTree = ""; };
C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchViews.swift; sourceTree = ""; };
C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollPositionPolicy.swift; sourceTree = ""; };
+ C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaSaslFailureModule.swift; sourceTree = ""; };
+ C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySyncTests.swift; sourceTree = ""; };
C86EBBD15843C6FB110C798E /* MediaFileIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileIO.swift; sourceTree = ""; };
C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntryTests.swift; sourceTree = ""; };
+ C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRotationPolicy.swift; sourceTree = ""; };
+ CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = ""; };
+ CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftChatView.swift; sourceTree = ""; };
CCE381301A55B1C4F333B523 /* CredentialVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialVault.swift; sourceTree = ""; };
CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreferenceTests.swift; sourceTree = ""; };
D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNotePreview.swift; sourceTree = ""; };
D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpointTests.swift; sourceTree = ""; };
- D3AA66547A2AA2D0979DA76E /* MainChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatView.swift; sourceTree = ""; };
D414B62940A9234C51C3D14E /* MessageReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReaction.swift; sourceTree = ""; };
D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarImageProcessor.swift; sourceTree = ""; };
+ D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageDeliveryTests.swift; sourceTree = ""; };
D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessagePlayer.swift; sourceTree = ""; };
D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayer.swift; sourceTree = ""; };
D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpoint.swift; sourceTree = ""; };
D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = ""; };
- DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateSync.swift; sourceTree = ""; };
+ DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ E1180AA3C5AC03BB48393D97 /* MessageBubbleEntrance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubbleEntrance.swift; sourceTree = ""; };
E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicy.swift; sourceTree = ""; };
+ E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = ""; };
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = ""; };
- EA54340DD785335237158BEF /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = ""; };
+ EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = ""; };
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = ""; };
+ F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; };
+ F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaSaslChallengeModule.swift; sourceTree = ""; };
F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngineTests.swift; sourceTree = ""; };
F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = ""; };
F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = ""; };
F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRosterStore.swift; sourceTree = ""; };
- FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveTests.swift; sourceTree = ""; };
+ FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicyTests.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -373,6 +495,7 @@
23AE47B738853110F72C0580 /* Martin in Frameworks */,
992195D6AE64DF2C24A52956 /* MartinOMEMO in Frameworks */,
137816B417A865C97F935A00 /* WebRTC in Frameworks */,
+ 24E3C4EEC4EB5116982D2F4E /* OpenSSL in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -383,6 +506,7 @@
86D39FA124355A8B53903164 /* Martin in Frameworks */,
32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */,
6B311EF7CF11B52E75D929F0 /* WebRTC in Frameworks */,
+ 39C3E7AC663AB6C8DACFFAA8 /* OpenSSL in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -392,12 +516,24 @@
0A7F1E12AC8E617F12AC1410 /* XMPP */ = {
isa = PBXGroup;
children = (
+ 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */,
1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */,
72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */,
+ 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */,
+ 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */,
24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */,
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */,
F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */,
+ F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */,
+ C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */,
+ 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */,
+ 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */,
+ 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */,
+ DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */,
+ 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */,
+ B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */,
F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */,
+ 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */,
0DE298382FC70ECF1513BD4A /* XMPPService.swift */,
);
path = XMPP;
@@ -407,6 +543,7 @@
isa = PBXGroup;
children = (
2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */,
+ 47680B7C615374A8C245818F /* AppLockPolicy.swift */,
2F36BB344D0FDF23127DF069 /* AppModel.swift */,
3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */,
D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */,
@@ -416,10 +553,13 @@
C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */,
6079CE75BAB995515A8D5460 /* CallSnapshot.swift */,
12C6A6B83DA076867481CCF3 /* ChatMessage.swift */,
+ B311D6C0234E1C910457FCE0 /* ChatMessageEntrancePolicy.swift */,
C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */,
13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */,
1C3E51BB890F05FD02C71F6E /* ChatTypingPolicy.swift */,
7846C2EEE2551C6690A4FDC6 /* Conversation.swift */,
+ 876CDF98063A4C948B00C7CE /* DeviceResource.swift */,
+ F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */,
5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */,
BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */,
278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */,
@@ -430,9 +570,13 @@
6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */,
9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */,
6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */,
+ 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */,
A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */,
+ 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */,
+ E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */,
E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */,
96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */,
+ C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */,
);
path = Models;
sourceTree = "";
@@ -441,8 +585,10 @@
isa = PBXGroup;
children = (
235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */,
+ 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */,
+ 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */,
32DBAB8D25603892C7D67A23 /* AvatarCache.swift */,
- 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */,
+ EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */,
);
path = Persistence;
sourceTree = "";
@@ -481,18 +627,30 @@
path = Watch;
sourceTree = "";
};
+ 307BDB7CACD11611A6CCDC4E /* UITests */ = {
+ isa = PBXGroup;
+ children = (
+ 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */,
+ 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */,
+ );
+ path = UITests;
+ sourceTree = "";
+ };
4D0D58FD94D94B4D409033AF /* Tests */ = {
isa = PBXGroup;
children = (
101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */,
+ FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */,
0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */,
+ ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */,
D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */,
A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */,
61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */,
22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */,
+ C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */,
C0590E38A89EC4CC78791C22 /* CallSDPOrderingTests.swift */,
375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */,
- FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */,
+ D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */,
24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */,
C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */,
C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */,
@@ -501,9 +659,11 @@
CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */,
37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */,
6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */,
+ 4170740AD7BA5D0EC8C870F0 /* LumaOMEMO2Tests.swift */,
772A4F24CAF527E328657749 /* MediaFileIOTests.swift */,
A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */,
B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */,
+ 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */,
247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */,
76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */,
6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */,
@@ -513,8 +673,17 @@
BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */,
77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */,
F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */,
+ A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */,
+ C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */,
+ 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */,
+ 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */,
+ 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */,
+ 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */,
+ 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */,
+ 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */,
9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */,
D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */,
+ 4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */,
33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */,
1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */,
);
@@ -524,6 +693,7 @@
50062B606E5AC6EF7E5AE47F /* Security */ = {
isa = PBXGroup;
children = (
+ 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */,
5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */,
CCE381301A55B1C4F333B523 /* CredentialVault.swift */,
);
@@ -533,6 +703,7 @@
6BC9FCFF61F5366ED010F59A /* UI */ = {
isa = PBXGroup;
children = (
+ 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */,
78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */,
9B11FED2D58621C071C3AD14 /* CallView.swift */,
60A97545E6E481D45E0BB20F /* ChatView.swift */,
@@ -541,10 +712,12 @@
BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */,
2786A15415F65305F78D892C /* LocationPickerView.swift */,
05EEEE8A125347F7C5014A09 /* LoginView.swift */,
- D3AA66547A2AA2D0979DA76E /* MainChatView.swift */,
+ 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */,
43A4CE3E678096BA76F2988C /* NewChatView.swift */,
99BE34DC082D19007241AA62 /* NewGroupView.swift */,
+ 686F6A6EB3740DE94310653B /* PreviewSupport.swift */,
515DD6F1F80A848C39EBAE84 /* RootView.swift */,
+ CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */,
9A338A8EB30206647BDD922F /* SettingsView.swift */,
5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */,
0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */,
@@ -556,8 +729,11 @@
751CBF99010567D043B5DA42 = {
isa = PBXGroup;
children = (
+ FFFA7B7564720060678770FC /* Packages */,
+ D1D74E43299CD30D89B80F74 /* Resources */,
2A98AB59ED6FB8802C301CF4 /* Sources */,
4D0D58FD94D94B4D409033AF /* Tests */,
+ 307BDB7CACD11611A6CCDC4E /* UITests */,
90CFB4B53B5F9EF91D718FC2 /* Products */,
);
indentWidth = 4;
@@ -579,6 +755,7 @@
D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */,
A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */,
1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */,
+ AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */,
C86EBBD15843C6FB110C798E /* MediaFileIO.swift */,
26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */,
3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */,
@@ -593,34 +770,56 @@
isa = PBXGroup;
children = (
DF8C531E464C9C4EDE2648C6 /* Luma.app */,
- EA54340DD785335237158BEF /* Luma.app */,
+ EA54340DD785335237158BEF /* LumaMac.app */,
6317FAA29F22A3EA8450208F /* LumaTests.xctest */,
+ 96720381802141EBA1DA9772 /* LumaUITests.xctest */,
19D4E9404712E8643E31CB93 /* LumaWatch.app */,
);
name = Products;
sourceTree = "";
};
+ D1D74E43299CD30D89B80F74 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */,
+ 7C9866558B72CB1909A01EEC /* Shared.xcassets */,
+ );
+ path = Resources;
+ sourceTree = "";
+ };
E27D62EEC5FD9AEBB7057485 /* Components */ = {
isa = PBXGroup;
children = (
D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */,
6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */,
0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */,
+ 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */,
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */,
758B448979C845F56168A51F /* ConversationRow.swift */,
+ 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */,
D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */,
B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */,
4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */,
03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */,
+ E1180AA3C5AC03BB48393D97 /* MessageBubbleEntrance.swift */,
8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */,
19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */,
5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */,
+ CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */,
A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */,
D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */,
);
path = Components;
sourceTree = "";
};
+ FFFA7B7564720060678770FC /* Packages */ = {
+ isa = PBXGroup;
+ children = (
+ B2DC8B629E8D4F078B192FAB /* MartinOMEMO */,
+ );
+ name = Packages;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -629,19 +828,19 @@
buildConfigurationList = 39082DED094B543B7F177A5B /* Build configuration list for PBXNativeTarget "Luma" */;
buildPhases = (
71162CE529E90DAEDA4BB5A5 /* Sources */,
+ 988CDDC80A2A68B4C03DF20F /* Resources */,
94D6F6F04126826503BA1E53 /* Frameworks */,
- 8C792A6B9B9150263A7D975B /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
- 41772D492E3D837DE18E4556 /* PBXTargetDependency */,
);
name = Luma;
packageProductDependencies = (
4BD4324EE01A6DD46EBE7FDA /* Martin */,
371134B6F3E284082E28FE08 /* MartinOMEMO */,
EFCAF9B0DE67466AF22E76C3 /* WebRTC */,
+ 01363A8EB6E9A3C97C1B316D /* OpenSSL */,
);
productName = Luma;
productReference = DF8C531E464C9C4EDE2648C6 /* Luma.app */;
@@ -652,6 +851,7 @@
buildConfigurationList = 23A91CAAA509D787E736CA14 /* Build configuration list for PBXNativeTarget "LumaMac" */;
buildPhases = (
A14B6411EF58F6392A388E9C /* Sources */,
+ A384208D84D36B531C7EE07C /* Resources */,
BA7C6C9ED1A007DDBA5E2B35 /* Frameworks */,
);
buildRules = (
@@ -663,16 +863,36 @@
6EDEC6EEE315B77491214398 /* Martin */,
7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */,
2A745A82DD6F34F0CB46B52B /* WebRTC */,
+ A99B4C1B1459E1C34F214164 /* OpenSSL */,
);
productName = LumaMac;
- productReference = EA54340DD785335237158BEF /* Luma.app */;
+ productReference = EA54340DD785335237158BEF /* LumaMac.app */;
productType = "com.apple.product-type.application";
};
+ A086B6D25E7969CB643F1C8A /* LumaUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 06AF122279B2585E145DB210 /* Build configuration list for PBXNativeTarget "LumaUITests" */;
+ buildPhases = (
+ DE1009798E1797BDAC72B608 /* Sources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ C675A3F8639BBF6792B09F39 /* PBXTargetDependency */,
+ );
+ name = LumaUITests;
+ packageProductDependencies = (
+ );
+ productName = LumaUITests;
+ productReference = 96720381802141EBA1DA9772 /* LumaUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
BAB78463D7DE07A8F7B3D27D /* LumaWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5D8E754B3FFEE24B370D350E /* Build configuration list for PBXNativeTarget "LumaWatch" */;
buildPhases = (
90BB4F269E01888E36AAD92A /* Sources */,
+ 20BDAC9BC4C78DB60C5FCD00 /* Resources */,
);
buildRules = (
);
@@ -710,7 +930,21 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
- LastUpgradeCheck = 2660;
+ LastUpgradeCheck = 1600;
+ TargetAttributes = {
+ 0565F6C7DA26F0482928704E = {
+ DevelopmentTeam = K3AS449A74;
+ };
+ 74A5CD40283A16247F9F36B2 = {
+ DevelopmentTeam = K3AS449A74;
+ };
+ A086B6D25E7969CB643F1C8A = {
+ TestTargetID = 0565F6C7DA26F0482928704E;
+ };
+ BAB78463D7DE07A8F7B3D27D = {
+ DevelopmentTeam = K3AS449A74;
+ };
+ };
};
buildConfigurationList = DEFB5A06B29D6BCD602A2291 /* Build configuration list for PBXProject "Luma" */;
developmentRegion = ru;
@@ -723,8 +957,9 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
277642F04D20E1E2B6BE565F /* XCRemoteSwiftPackageReference "Martin" */,
- 43A0FD3A38EEEB7A14708794 /* XCRemoteSwiftPackageReference "MartinOMEMO" */,
+ CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */,
251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */,
+ 3B2A3CB97916A28030D0A4E0 /* XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 90CFB4B53B5F9EF91D718FC2 /* Products */;
@@ -733,12 +968,43 @@
targets = (
0565F6C7DA26F0482928704E /* Luma */,
74A5CD40283A16247F9F36B2 /* LumaMac */,
- D4CD85C144854C8DA57B3903 /* LumaTests */,
BAB78463D7DE07A8F7B3D27D /* LumaWatch */,
+ D4CD85C144854C8DA57B3903 /* LumaTests */,
+ A086B6D25E7969CB643F1C8A /* LumaUITests */,
);
};
/* End PBXProject section */
+/* Begin PBXResourcesBuildPhase section */
+ 20BDAC9BC4C78DB60C5FCD00 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 2987B54F6E358AAE2EE19993 /* AppAssets.xcassets in Resources */,
+ E449F2B17E6D5053EBA7F6A8 /* Shared.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 988CDDC80A2A68B4C03DF20F /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 9B53D50C5EDE4AD507C3F5B6 /* AppAssets.xcassets in Resources */,
+ 1F467DB76CE93C90F09E2991 /* Shared.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A384208D84D36B531C7EE07C /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 2F175DFA6D0FBF31B7EED21F /* AppAssets.xcassets in Resources */,
+ 7227469C6E25E0F4FF7FC805 /* Shared.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
/* Begin PBXSourcesBuildPhase section */
71162CE529E90DAEDA4BB5A5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -746,8 +1012,13 @@
files = (
00FCAB7210D005D2C30747CB /* AccountConfiguration.swift in Sources */,
32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */,
+ 2BC210A585AAA9D50ECC9A4B /* AppLockPolicy.swift in Sources */,
+ 7FBD6C44C2CA9D50E28E823A /* AppLockVault.swift in Sources */,
+ 920CB33C235AF4BDB4107BB7 /* AppLockView.swift in Sources */,
B492852FD89322434B0914AE /* AppModel.swift in Sources */,
F057C0C84B541A5564D0E7E7 /* ArchiveMessageBatchPolicy.swift in Sources */,
+ 7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */,
+ E6890C743C5BBD0B287DDB53 /* ArchiveStore.swift in Sources */,
ED3869D5658BAA59F55B1E3D /* ArchiveSyncCheckpoint.swift in Sources */,
1AC03E6E9EA1D0C1F59F7619 /* ArchiveSyncPagination.swift in Sources */,
088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */,
@@ -760,12 +1031,14 @@
E9FA22C965454FA6890DC3D5 /* AvatarImageProcessor.swift in Sources */,
A2AF2B19A1E9D9B8D6E1CD63 /* AvatarView.swift in Sources */,
FC5986CE50C84FA77983767C /* BubbleShapes.swift in Sources */,
+ 81091B6BA7D793CA233812ED /* CallHistorySync.swift in Sources */,
2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */,
6FB79BEEEF151D297F4EBB33 /* CallView.swift in Sources */,
73A21AFC42D6C00E6C2C5082 /* CertificateTrustEvaluator.swift in Sources */,
- AEC21F39F40AFBD3B4225B3C /* ChatArchive.swift in Sources */,
+ 97A3A0635ADA8A8227C94216 /* ChatBubbleShape.swift in Sources */,
7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */,
66489D4D15366683D3095B3D /* ChatMessage.swift in Sources */,
+ 65D2B572D508EF10931420FD /* ChatMessageEntrancePolicy.swift in Sources */,
D5E6B1859101ABBD33D90C3C /* ChatScrollPositionPolicy.swift in Sources */,
1E239B9338CEEA7EA704626C /* ChatTimelineEntry.swift in Sources */,
433A7B65E635FB59021F7D87 /* ChatTypingPolicy.swift in Sources */,
@@ -775,22 +1048,34 @@
1655A2B7ACF4003EF41A1AB8 /* ConversationRow.swift in Sources */,
64F4AD83D86B8900DE69F117 /* CredentialVault.swift in Sources */,
EE4DFDBA97562C53C53FBF51 /* CryptoKitAESGCMEngine.swift in Sources */,
+ 2D32E57F5CE1BF2EAE078492 /* DeviceResource.swift in Sources */,
+ BEF48E72571CF850AAA7F17F /* EmojiCatalog.swift in Sources */,
+ 791AB9983E2146DBF1D7B606 /* EmojiPickerView.swift in Sources */,
9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */,
9393464C4C3BE5F9546FE8BB /* EncryptionPreference.swift in Sources */,
8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */,
D6ED224CC13439A99A388676 /* GeoLocation.swift in Sources */,
DBB0216BFAB04C3847FEE95C /* GroupInfoView.swift in Sources */,
DA77E8A49D2D091EC0975DD3 /* InlineVideoPlayer.swift in Sources */,
+ CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */,
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */,
814FE6C4CB53207F236E9236 /* LocationPickerView.swift in Sources */,
89712B216A98861B5D994587 /* LocationProvider.swift in Sources */,
F80493F220991FF8A7CFF681 /* LoginView.swift in Sources */,
2D83457A8857592E23289EF7 /* LumaApp.swift in Sources */,
5415AF453AB8FCDA55FB59AD /* LumaCallEngine.swift in Sources */,
+ 5459CF1808474747AF46A366 /* LumaConnectionStatsModule.swift in Sources */,
+ 1C78234B3FA4E528C7F701B7 /* LumaOMEMO2Module.swift in Sources */,
FB1052104B90994CF03FE6A2 /* LumaOMEMOStore.swift in Sources */,
CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */,
F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */,
- 08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */,
+ 059A5169D66AD8F04B341403 /* LumaSaslChallengeModule.swift in Sources */,
+ 0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */,
+ ED1C54A2FB844226AA284CCC /* LumaScramPlusMechanism.swift in Sources */,
+ E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */,
+ 670970182A27A26B8E44E687 /* LumaTLSNetworkProcessor.swift in Sources */,
+ D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */,
+ CA39FA78E8BFE26BEBF976E5 /* MediaDownloadService.swift in Sources */,
0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */,
2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */,
7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */,
@@ -800,6 +1085,7 @@
B33870CD89289E44882750C3 /* MediaViewer.swift in Sources */,
EBAF4861DD93FBEAA4B37D1C /* MediaViewerItem.swift in Sources */,
230E0DAA5396EA507D6F6D47 /* MessageBubble.swift in Sources */,
+ 33EF454D77366A0E9137D824 /* MessageBubbleEntrance.swift in Sources */,
FD9F8640841236BB78563573 /* MessageReaction.swift in Sources */,
30BE19F459FDDAD640F66C68 /* MessageReplyFallback.swift in Sources */,
94FB3597E01FC508F872861E /* MessageReplySwipePolicy.swift in Sources */,
@@ -808,20 +1094,31 @@
D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */,
66613FF7E056788222552D6C /* NotificationPolicy.swift in Sources */,
7809154E961CC8EFE7EE852F /* OMEMODevice.swift in Sources */,
+ A73E17CEC3F4E8624102FC17 /* OlderHistoryScrollPolicy.swift in Sources */,
6216C6AA3787BBF776D78E91 /* PhoneWatchBridge.swift in Sources */,
C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */,
+ 5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */,
067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */,
+ 1BD08940BB8E5DE4EDB2AF30 /* ReadStateSync.swift in Sources */,
67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */,
ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */,
+ D6718F8B1359CD06F44A91A8 /* SASLMechanismPreference.swift in Sources */,
+ 9F288ACC92CD62A2E0CCE157 /* SASLprep.swift in Sources */,
+ A4A742F3BBEFCAFD29975968 /* SaslFailureMessage.swift in Sources */,
+ 01E4131ED4DBED0BEAD465A2 /* ServerInfoView.swift in Sources */,
+ 9AB2A519206C5016B0D8C6DE /* ServerInformation.swift in Sources */,
CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */,
D3777F3C0A81F08A9983048B /* StaticDNSSrvResolver.swift in Sources */,
+ 1C948E97A14DBA90C24A68FF /* SwiftChatView.swift in Sources */,
3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */,
+ 64C9A8E42046AC0FCB701613 /* TLSVersionProbe.swift in Sources */,
E67E0D2C3FBA2C3739B692E0 /* VideoAttachmentPreview.swift in Sources */,
3C2727D165D2EF1CC742EEF7 /* VideoNoteCaptureView.swift in Sources */,
44AF85896EE023775B91DE8B /* VideoNotePreview.swift in Sources */,
01EBE88C86DB1C4745D1AB08 /* VideoNoteRecorder.swift in Sources */,
275501728E0597A8E53F1D4D /* VideoNoteRecordingCompletionPolicy.swift in Sources */,
1D5B3487BDC63B051A33D874 /* VideoNoteRecordingLifecycle.swift in Sources */,
+ 32E78EE5035DD08181B50B86 /* VideoNoteRotationPolicy.swift in Sources */,
F8B9CBE6849C45F828BE3BD4 /* XMPPService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -843,8 +1140,13 @@
files = (
D551F2AA5DCCCCDD8D8FB9BB /* AccountConfiguration.swift in Sources */,
3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */,
+ 14059C4EF59FBBDCC5338420 /* AppLockPolicy.swift in Sources */,
+ C23C74AEDA88885A209C9BB1 /* AppLockVault.swift in Sources */,
+ 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */,
21AAF3FB5846B1B54BBEE7ED /* AppModel.swift in Sources */,
9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */,
+ AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */,
+ 4E1B116771922F92FEEB079B /* ArchiveStore.swift in Sources */,
4EFADFE085B42E0B0593E501 /* ArchiveSyncCheckpoint.swift in Sources */,
79169D5FE931EBE0D0801516 /* ArchiveSyncPagination.swift in Sources */,
496D99C625D771D6B91D99E7 /* ArchiveSyncRecoveryPolicy.swift in Sources */,
@@ -857,12 +1159,14 @@
203D6124DF78461D8E86FDA8 /* AvatarImageProcessor.swift in Sources */,
A29C828AC4B571941BF0B8E9 /* AvatarView.swift in Sources */,
6DC36AA33268FC6457FADAE2 /* BubbleShapes.swift in Sources */,
+ 8831E0A88F3759766F9BCC34 /* CallHistorySync.swift in Sources */,
4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */,
087E600676B3F6134BB26112 /* CallView.swift in Sources */,
3D74BDDE2D978A2CC795E33A /* CertificateTrustEvaluator.swift in Sources */,
- 5FB4DEEA5321D80CCD63B864 /* ChatArchive.swift in Sources */,
+ 2A7611650AB52EDABB978362 /* ChatBubbleShape.swift in Sources */,
6FCF6A4EFF8AE1FBFC2D81E1 /* ChatMediaImageCache.swift in Sources */,
8714A5A67956D6A78D4D3E4B /* ChatMessage.swift in Sources */,
+ 4B65093C19D148E04A675440 /* ChatMessageEntrancePolicy.swift in Sources */,
8B98B818AC4379F03907B0E0 /* ChatScrollPositionPolicy.swift in Sources */,
7F7BA6805C1D1EBCEAFDD3AD /* ChatTimelineEntry.swift in Sources */,
B4AA654DF3A53AAAAC23915D /* ChatTypingPolicy.swift in Sources */,
@@ -872,22 +1176,34 @@
51AE33BE56ED4543014119D0 /* ConversationRow.swift in Sources */,
EE2BF07D86C00AB6358EA5AE /* CredentialVault.swift in Sources */,
E582A0424A63CA16DDA4E984 /* CryptoKitAESGCMEngine.swift in Sources */,
+ D7179E7F8127A702FC58D3D4 /* DeviceResource.swift in Sources */,
+ FDB78F0A58D66E36B9E25A47 /* EmojiCatalog.swift in Sources */,
+ 411136B21632A796E26B207C /* EmojiPickerView.swift in Sources */,
FCD330D04ED5B87E28883E58 /* EncryptionDevicesView.swift in Sources */,
A07ABE478F7F48C3FEDC22D5 /* EncryptionPreference.swift in Sources */,
25A9ADB0FF1496A4D3EF431D /* ForwardMessageView.swift in Sources */,
7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */,
D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */,
6AB86E72201EB1A61898AD5F /* InlineVideoPlayer.swift in Sources */,
+ F881ABC5077A3330B29C0DE6 /* LegacyArchiveImporter.swift in Sources */,
64C8B9876278B711CC6479E0 /* LocationMessagePreview.swift in Sources */,
B9CAB8C2C16A5A05CE38E8D7 /* LocationPickerView.swift in Sources */,
49DC659401787CF44A33B620 /* LocationProvider.swift in Sources */,
3D67F14421F89B6DA4400642 /* LoginView.swift in Sources */,
7F012252FA73A8282B92323C /* LumaApp.swift in Sources */,
7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */,
+ B13513FE5DAD462086B8B0B0 /* LumaConnectionStatsModule.swift in Sources */,
+ E4B1FDFFD3BFFC47B5F154DE /* LumaOMEMO2Module.swift in Sources */,
1B20F2D3E4F18E6094B103BF /* LumaOMEMOStore.swift in Sources */,
03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */,
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */,
- 7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */,
+ 7D095DA2AAD5E7D813586E7D /* LumaSaslChallengeModule.swift in Sources */,
+ C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */,
+ FCC5133038CDBEC39B5B42D3 /* LumaScramPlusMechanism.swift in Sources */,
+ 127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */,
+ F5121F29EF3BEFD4FD54DD12 /* LumaTLSNetworkProcessor.swift in Sources */,
+ 6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */,
+ 523D54621DD4DDD54EA8D510 /* MediaDownloadService.swift in Sources */,
EFC29F3543B1371E63D48510 /* MediaFileIO.swift in Sources */,
AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */,
D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */,
@@ -897,6 +1213,7 @@
CC5297F90A3DBD45C5E41A3A /* MediaViewer.swift in Sources */,
2C5BAAEF4EC7AAE5FE0FEEDB /* MediaViewerItem.swift in Sources */,
A90142509385DE6E83818953 /* MessageBubble.swift in Sources */,
+ 52CA5DEE0CF7697D1B2D1FEA /* MessageBubbleEntrance.swift in Sources */,
ECCD95DD4E16E0B393C707A9 /* MessageReaction.swift in Sources */,
C2CC9BABAE68FA9258694766 /* MessageReplyFallback.swift in Sources */,
D4E1CF1F7D948B2E72C063BE /* MessageReplySwipePolicy.swift in Sources */,
@@ -905,20 +1222,31 @@
B91AA414D00C6B54BE32ED25 /* NotificationCoordinator.swift in Sources */,
D63F32533880B0D715ECFC79 /* NotificationPolicy.swift in Sources */,
097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */,
+ AF1AE686F7B4915C4636099D /* OlderHistoryScrollPolicy.swift in Sources */,
FF07DC53E2877BFDE726132C /* PhoneWatchBridge.swift in Sources */,
46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */,
+ 6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */,
C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */,
+ 9D2352A11D04AB0F2DDBD368 /* ReadStateSync.swift in Sources */,
3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */,
3E3A82A737D9009D9BDC04E0 /* RootView.swift in Sources */,
+ 120BD1C8A163015AF27FFA42 /* SASLMechanismPreference.swift in Sources */,
+ 3BA375F1E0934FB4DD2148F9 /* SASLprep.swift in Sources */,
+ F80767EC81E8CAFAA280DD6A /* SaslFailureMessage.swift in Sources */,
+ 3D3DDA2292673CE4FF397174 /* ServerInfoView.swift in Sources */,
+ 939ABD4DED652588957491CF /* ServerInformation.swift in Sources */,
E2245416CB055ECEF0C5F81D /* SettingsView.swift in Sources */,
0ECAB3E52DD12AD78AABE5F5 /* StaticDNSSrvResolver.swift in Sources */,
+ 945014504AA02172175B48D2 /* SwiftChatView.swift in Sources */,
46924A3275BFEFD2218AD748 /* SystemPhotoCameraView.swift in Sources */,
+ 2A728A647EDD4F098802C64C /* TLSVersionProbe.swift in Sources */,
6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */,
640C30ABB02E6486E6B7665E /* VideoNoteCaptureView.swift in Sources */,
03629CA43EB6D367498E90E8 /* VideoNotePreview.swift in Sources */,
1CB1DAF27B8632B2E071338E /* VideoNoteRecorder.swift in Sources */,
B429AC460AF49367463F59A8 /* VideoNoteRecordingCompletionPolicy.swift in Sources */,
CB66B3FCB7036E9AE50B6BF3 /* VideoNoteRecordingLifecycle.swift in Sources */,
+ 054AED20075FF49401C3E59C /* VideoNoteRotationPolicy.swift in Sources */,
FE958AD8FF4A43DB9A887882 /* XMPPService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -928,14 +1256,17 @@
buildActionMask = 2147483647;
files = (
6B0B8F3CAC1E5854C6F81E70 /* AccountConfigurationTests.swift in Sources */,
+ 4DE3B27CFA3D524590FB751A /* AppLockPolicyTests.swift in Sources */,
822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */,
+ F320BBE2BE143D15398C46F9 /* ArchiveStoreTests.swift in Sources */,
C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */,
7425493304D731FA218E9151 /* ArchiveSyncPaginationTests.swift in Sources */,
481F1C57B07AF615A3629C7E /* ArchiveSyncRecoveryPolicyTests.swift in Sources */,
10273AF92A952B707C58AEE6 /* ArchiveSyncWorkBudgetTests.swift in Sources */,
+ 6950D2E35897C3A6C537AE08 /* CallHistorySyncTests.swift in Sources */,
B7ED810A1DD43B717DDC40B3 /* CallSDPOrderingTests.swift in Sources */,
61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */,
- C557E56AE73731A5BC2FD327 /* ChatArchiveTests.swift in Sources */,
+ 7B3DD63332C8149FAE907B0E /* ChatMessageDeliveryTests.swift in Sources */,
12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */,
5409F5C90FBE0817F4AC8176 /* ChatTimelineEntryTests.swift in Sources */,
3F1571511219DBA18F67209F /* ChatTypingPolicyTests.swift in Sources */,
@@ -944,9 +1275,11 @@
03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */,
A69C1CD6CC1FF13631044BA3 /* GeoLocationTests.swift in Sources */,
60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */,
+ A87AB7D40BD589F5EBCCBD6F /* LumaOMEMO2Tests.swift in Sources */,
DC4FAE63AB8AF89C30512763 /* MediaFileIOTests.swift in Sources */,
10C5382981A87A2963DD397C /* MediaMetadataTests.swift in Sources */,
958FD3CBEC600651E1171B55 /* MediaPickerSelectionPolicyTests.swift in Sources */,
+ 312B8D266C5BEC4F7B2F3276 /* MediaPreviewProcessorVideoTests.swift in Sources */,
4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */,
F27ACACEF60AFAB5AF96EF98 /* MediaTimeFormatterTests.swift in Sources */,
AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */,
@@ -956,26 +1289,44 @@
5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */,
0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */,
9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */,
+ A6B05CBBA8A2B4C33B41F47D /* OlderHistoryScrollPolicyTests.swift in Sources */,
+ D0EB41F8E01D4B654254E101 /* ReadStateSyncTests.swift in Sources */,
+ 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */,
+ 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */,
+ DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */,
+ 662B8E72F00F5A77DE28819A /* SCRAMPlusTests.swift in Sources */,
+ 3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */,
+ 3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */,
FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */,
E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */,
+ 48C34A0CBF309A652AE350B3 /* VideoNoteRotationPolicyTests.swift in Sources */,
89E2B0373E2DA2B25863A866 /* VideoNoteStopPolicyTests.swift in Sources */,
7306BA14F94818248594AF91 /* WatchVoiceMessageTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+ DE1009798E1797BDAC72B608 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 9E36AF2F40D4EC3AF5CE1680 /* LayoutCheckUITests.swift in Sources */,
+ 48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
- 41772D492E3D837DE18E4556 /* PBXTargetDependency */ = {
- isa = PBXTargetDependency;
- target = BAB78463D7DE07A8F7B3D27D /* LumaWatch */;
- targetProxy = 0722750D38A3B60127F87761 /* PBXContainerItemProxy */;
- };
451A9C42B8C63659ECBC26D9 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0565F6C7DA26F0482928704E /* Luma */;
targetProxy = 277A193FE4C68A777257EF0D /* PBXContainerItemProxy */;
};
+ C675A3F8639BBF6792B09F39 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 0565F6C7DA26F0482928704E /* Luma */;
+ targetProxy = 5C6218BA484572FDB4727ECA /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -987,15 +1338,14 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Config/LumaMac.entitlements;
COMBINE_HIDPI_IMAGES = YES;
- DEAD_CODE_STRIPPING = YES;
- "DEVELOPMENT_TEAM[sdk=macosx*]" = K3AS449A74;
+ DEVELOPMENT_TEAM = K3AS449A74;
INFOPLIST_FILE = "Config/LumaMac-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.macos;
PRODUCT_NAME = Luma;
SDKROOT = macosx;
@@ -1010,15 +1360,14 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Config/LumaMac.entitlements;
COMBINE_HIDPI_IMAGES = YES;
- DEAD_CODE_STRIPPING = YES;
- "DEVELOPMENT_TEAM[sdk=macosx*]" = K3AS449A74;
+ DEVELOPMENT_TEAM = K3AS449A74;
INFOPLIST_FILE = "Config/LumaMac-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.macos;
PRODUCT_NAME = Luma;
SDKROOT = macosx;
@@ -1036,15 +1385,16 @@
DEVELOPMENT_TEAM = K3AS449A74;
INFOPLIST_FILE = "Config/Luma-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(COMPANION_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = Luma;
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
+ SWIFT_SUPPRESS_WARNINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
@@ -1064,7 +1414,7 @@
SDKROOT = watchos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = 4;
- WATCHOS_DEPLOYMENT_TARGET = 10.0;
+ WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
@@ -1073,7 +1423,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1086,12 +1436,30 @@
};
name = Release;
};
+ 64B3F29554E68D692FBF07CD /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.uitests;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = Luma;
+ };
+ name = Release;
+ };
6A07A459DB18752AD74A145A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1108,7 +1476,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1139,9 +1506,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COMPANION_BUNDLE_IDENTIFIER = app.luma.chat;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 31;
- DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -1160,12 +1527,11 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MARKETING_VERSION = 0.10.6;
+ MARKETING_VERSION = 20261013.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.9;
@@ -1187,7 +1553,7 @@
SDKROOT = watchos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = 4;
- WATCHOS_DEPLOYMENT_TARGET = 10.0;
+ WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
@@ -1195,7 +1561,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1226,9 +1591,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COMPANION_BUNDLE_IDENTIFIER = app.luma.chat;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 31;
- DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -1241,17 +1606,34 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MARKETING_VERSION = 0.10.6;
+ MARKETING_VERSION = 20261013.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.9;
};
name = Release;
};
+ E381B8E38C4B5D9CBB9E6416 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.uitests;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = Luma;
+ };
+ name = Debug;
+ };
FEECD67107BB7AE74F3574C8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -1263,15 +1645,16 @@
DEVELOPMENT_TEAM = K3AS449A74;
INFOPLIST_FILE = "Config/Luma-Info.plist";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(COMPANION_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = Luma;
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
+ SWIFT_SUPPRESS_WARNINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@@ -1288,6 +1671,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
+ 06AF122279B2585E145DB210 /* Build configuration list for PBXNativeTarget "LumaUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E381B8E38C4B5D9CBB9E6416 /* Debug */,
+ 64B3F29554E68D692FBF07CD /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
23A91CAAA509D787E736CA14 /* Build configuration list for PBXNativeTarget "LumaMac" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -1326,6 +1718,13 @@
};
/* End XCConfigurationList section */
+/* Begin XCLocalSwiftPackageReference section */
+ 3B2A3CB97916A28030D0A4E0 /* XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ThirdParty/MartinOMEMO;
+ };
+/* End XCLocalSwiftPackageReference section */
+
/* Begin XCRemoteSwiftPackageReference section */
251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */ = {
isa = XCRemoteSwiftPackageReference;
@@ -1343,17 +1742,22 @@
version = 3.2.4;
};
};
- 43A0FD3A38EEEB7A14708794 /* XCRemoteSwiftPackageReference "MartinOMEMO" */ = {
+ CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */ = {
isa = XCRemoteSwiftPackageReference;
- repositoryURL = "https://github.com/tigase/MartinOMEMO.git";
+ repositoryURL = "https://github.com/krzyzanowskim/OpenSSL.git";
requirement = {
- kind = exactVersion;
- version = 2.2.3;
+ kind = upToNextMajorVersion;
+ minimumVersion = 3.4.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
+ 01363A8EB6E9A3C97C1B316D /* OpenSSL */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */;
+ productName = OpenSSL;
+ };
2A745A82DD6F34F0CB46B52B /* WebRTC */ = {
isa = XCSwiftPackageProductDependency;
package = 251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */;
@@ -1361,7 +1765,6 @@
};
371134B6F3E284082E28FE08 /* MartinOMEMO */ = {
isa = XCSwiftPackageProductDependency;
- package = 43A0FD3A38EEEB7A14708794 /* XCRemoteSwiftPackageReference "MartinOMEMO" */;
productName = MartinOMEMO;
};
4BD4324EE01A6DD46EBE7FDA /* Martin */ = {
@@ -1376,9 +1779,13 @@
};
7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */ = {
isa = XCSwiftPackageProductDependency;
- package = 43A0FD3A38EEEB7A14708794 /* XCRemoteSwiftPackageReference "MartinOMEMO" */;
productName = MartinOMEMO;
};
+ A99B4C1B1459E1C34F214164 /* OpenSSL */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */;
+ productName = OpenSSL;
+ };
EFCAF9B0DE67466AF22E76C3 /* WebRTC */ = {
isa = XCSwiftPackageProductDependency;
package = 251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */;
diff --git a/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 8546218..d0b6f4e 100644
--- a/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,4 @@
{
- "originHash" : "a9b7decfad90e115a57d2c941faf30bf1f041c5fcd0d87ea51c92b76dfac7ca8",
"pins" : [
{
"identity" : "libsignal",
@@ -20,12 +19,12 @@
}
},
{
- "identity" : "martinomemo",
+ "identity" : "openssl",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/tigase/MartinOMEMO.git",
+ "location" : "https://github.com/krzyzanowskim/OpenSSL.git",
"state" : {
- "revision" : "3c162154d646aa258c9a86c0a07655a536e55a94",
- "version" : "2.2.3"
+ "revision" : "4b1188ba947ddc23d03b13096b9e44b804e146c9",
+ "version" : "3.6.3000"
}
},
{
@@ -47,5 +46,5 @@
}
}
],
- "version" : 3
+ "version" : 2
}
diff --git a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme
index c16868a..e9e8b78 100644
--- a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme
+++ b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme
@@ -1,6 +1,6 @@
+
+
+
+
@@ -72,7 +71,6 @@
BuildableIdentifier = "primary"
BlueprintIdentifier = "BAB78463D7DE07A8F7B3D27D"
BuildableName = "LumaWatch.app"
- BlueprintName = "LumaWatch"
ReferencedContainer = "container:Luma.xcodeproj">
diff --git a/Makefile b/Makefile
index dd871b4..14e9b1f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,8 +1,9 @@
-.PHONY: project open verify clean
+.PHONY: project open verify clean reset
project:
@command -v xcodegen >/dev/null || (echo "Install XcodeGen first: brew install xcodegen" && exit 1)
xcodegen generate
+ @python3 Scripts/patch-xcodeproj.py
open: project
open Luma.xcodeproj
@@ -13,3 +14,12 @@ verify:
clean:
rm -rf Luma.xcodeproj DerivedData .build
+# Fixes Xcode GUI "Missing package product" errors: the GUI caches the
+# SwiftPM package graph in DerivedData, which goes stale when packages
+# change (e.g. the remote -> vendored local MartinOMEMO switch). Quit Xcode
+# first, run this, then reopen Luma.xcodeproj and let it re-resolve.
+reset:
+ rm -rf ~/Library/Developer/Xcode/DerivedData/Luma-*
+ rm -f Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+ @echo "Xcode package state cleared. Reopen Luma.xcodeproj to re-resolve packages."
+
diff --git a/README.md b/README.md
index 2c8b780..1255f8a 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,7 @@ Luma — рабочий MVP XMPP-клиента для iOS, iPadOS, macOS и App
iPhone, iPad, Mac и Apple Watch; один и тот же catalog явно подключён ко всем
app-target’ам, а имя закреплено в build settings и Info.plist;
- адаптивный SwiftUI-интерфейс для iPhone, iPad и Mac;
+- поддержка портретной и альбомной ориентации на iPhone и всех четырёх ориентаций на iPad;
- companion-приложение watchOS: последние чаты, диктовка/ответы, а также запись
голосового до 60 секунд с отменой и предпрослушиванием; `.m4a` передаётся на
iPhone фоновой файловой очередью WatchConnectivity, после чего iPhone
@@ -145,14 +146,18 @@ OMEMO, и для XEP-0084 аватаров. Для медиа и вложени
XMPP-сокет. Для production нужны Apple Developer credentials, APNs provider,
XEP-0357 push-компонент и регистрация приложения в этом компоненте.
- При первом запуске Luma запрашивает ограниченную последнюю страницу
- серверного MAM-архива, чтобы огромная история не блокировала интерфейс.
+ серверного MAM-архива, чтобы огромная история не блокировала интерфейс, и
+ показывает индикатор «Синхронизация истории…» только на этом первичном этапе.
После успешного прохода сохраняются серверный UID последней записи и время;
следующие подключения продолжают RSM строго после UID. Временное перекрытие
используется только для старых snapshots без UID или один раз, если сервер
- удалил сохранённый UID. Сырые stanza собираются вне main queue, расшифровываются
- по одной с уступкой UI, а готовый проход публикуется одной пачкой. Один
- foreground-проход также ограничен по страницам и времени; после ошибки он не
- перезапускает сам себя бесконечно.
+ удалил сохранённый UID. Сырые stanza собираются вне main queue, а расшифровка
+ OMEMO выполняется на отдельной фоновой очереди — главный поток освобождается на
+ время Signal-криптографии, поэтому большая история не лагает интерфейс.
+ Инкрементальная догрузка backlog продолжается в фоне без вечного спиннера, а
+ сообщения применяются атомарными пачками с дедупликацией по `origin-id`/stanza
+ id через хеш-индексы. После сбоя отдельной страницы синхронизация автоматически
+ повторяется ограниченное число раз, не дожидаясь повторного открытия приложения.
- Клиент не вводит собственного ограничения размера вложения: фактический
предел сообщает XEP-0363 upload-компонент сервера. Видеосообщение ограничено
60 секундами.
diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-45mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-45mm@2x.png
new file mode 100644
index 0000000..b3ceff8
Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-45mm@2x.png differ
diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-45mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-45mm@2x.png
new file mode 100644
index 0000000..a9b3e5f
Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-45mm@2x.png differ
diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-49mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-49mm@2x.png
new file mode 100644
index 0000000..1f780dc
Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-49mm@2x.png differ
diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json b/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json
index 0cfcd9b..70b2d6d 100644
--- a/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,47 +1,305 @@
{
"images" : [
- { "filename" : "AppIcon-iPhone-20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" },
- { "filename" : "AppIcon-iPhone-20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" },
- { "filename" : "AppIcon-iPhone-29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" },
- { "filename" : "AppIcon-iPhone-29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" },
- { "filename" : "AppIcon-iPhone-40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" },
- { "filename" : "AppIcon-iPhone-40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" },
- { "filename" : "AppIcon-iPhone-60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" },
- { "filename" : "AppIcon-iPhone-60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" },
- { "filename" : "AppIcon-iPad-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
- { "filename" : "AppIcon-iPad-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
- { "filename" : "AppIcon-iPad-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
- { "filename" : "AppIcon-iPad-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
- { "filename" : "AppIcon-iPad-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
- { "filename" : "AppIcon-iPad-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
- { "filename" : "AppIcon-iPad-76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" },
- { "filename" : "AppIcon-iPad-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
- { "filename" : "AppIcon-iPad-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
- { "filename" : "AppIcon-AppStore-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" },
- { "filename" : "AppIcon-Mac-16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
- { "filename" : "AppIcon-Mac-16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
- { "filename" : "AppIcon-Mac-32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
- { "filename" : "AppIcon-Mac-32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
- { "filename" : "AppIcon-Mac-128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
- { "filename" : "AppIcon-Mac-128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
- { "filename" : "AppIcon-Mac-256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
- { "filename" : "AppIcon-Mac-256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
- { "filename" : "AppIcon-Mac-512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
- { "filename" : "AppIcon-Mac-512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" },
- { "filename" : "AppIcon-Watch-Notification-38mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "24x24", "subtype" : "38mm" },
- { "filename" : "AppIcon-Watch-Notification-42mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "27.5x27.5", "subtype" : "42mm" },
- { "filename" : "AppIcon-Watch-Settings@2x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "2x", "size" : "29x29" },
- { "filename" : "AppIcon-Watch-Settings@3x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "3x", "size" : "29x29" },
- { "filename" : "AppIcon-Watch-Launcher-38mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "40x40", "subtype" : "38mm" },
- { "filename" : "AppIcon-Watch-Launcher-40mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "44x44", "subtype" : "40mm" },
- { "filename" : "AppIcon-Watch-Launcher-41mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "46x46", "subtype" : "41mm" },
- { "filename" : "AppIcon-Watch-Launcher-44mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "50x50", "subtype" : "44mm" },
- { "filename" : "AppIcon-Watch-Launcher-45mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "51x51", "subtype" : "45mm" },
- { "filename" : "AppIcon-Watch-Launcher-49mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "54x54", "subtype" : "49mm" },
- { "filename" : "AppIcon-Watch-QuickLook-38mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "86x86", "subtype" : "38mm" },
- { "filename" : "AppIcon-Watch-QuickLook-42mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "98x98", "subtype" : "42mm" },
- { "filename" : "AppIcon-Watch-QuickLook-44mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "108x108", "subtype" : "44mm" },
- { "filename" : "AppIcon-WatchStore-1024.png", "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" }
+ {
+ "filename" : "AppIcon-iPhone-20@2x.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "AppIcon-iPhone-20@3x.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "AppIcon-iPhone-29@2x.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-iPhone-29@3x.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-iPhone-40@2x.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "AppIcon-iPhone-40@3x.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "AppIcon-iPhone-60@2x.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "AppIcon-iPhone-60@3x.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "AppIcon-iPad-20@1x.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "AppIcon-iPad-20@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "AppIcon-iPad-29@1x.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-iPad-29@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-iPad-40@1x.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "AppIcon-iPad-40@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "AppIcon-iPad-76@1x.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "76x76"
+ },
+ {
+ "filename" : "AppIcon-iPad-76@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "76x76"
+ },
+ {
+ "filename" : "AppIcon-iPad-83.5@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "83.5x83.5"
+ },
+ {
+ "filename" : "AppIcon-AppStore-1024.png",
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ },
+ {
+ "filename" : "AppIcon-Mac-16.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "AppIcon-Mac-16@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "AppIcon-Mac-32.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "AppIcon-Mac-32@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "AppIcon-Mac-128.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "AppIcon-Mac-128@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "AppIcon-Mac-256.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "AppIcon-Mac-256@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "AppIcon-Mac-512.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "filename" : "AppIcon-Mac-512@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ },
+ {
+ "filename" : "AppIcon-Watch-Notification-38mm@2x.png",
+ "idiom" : "watch",
+ "role" : "notificationCenter",
+ "scale" : "2x",
+ "size" : "24x24",
+ "subtype" : "38mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-Notification-42mm@2x.png",
+ "idiom" : "watch",
+ "role" : "notificationCenter",
+ "scale" : "2x",
+ "size" : "27.5x27.5",
+ "subtype" : "42mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-Settings@2x.png",
+ "idiom" : "watch",
+ "role" : "companionSettings",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-Watch-Settings@3x.png",
+ "idiom" : "watch",
+ "role" : "companionSettings",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-Watch-Notification-45mm@2x.png",
+ "idiom" : "watch",
+ "role" : "notificationCenter",
+ "scale" : "2x",
+ "size" : "33x33",
+ "subtype" : "45mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-Launcher-38mm@2x.png",
+ "idiom" : "watch",
+ "role" : "appLauncher",
+ "scale" : "2x",
+ "size" : "40x40",
+ "subtype" : "38mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-Launcher-40mm@2x.png",
+ "idiom" : "watch",
+ "role" : "appLauncher",
+ "scale" : "2x",
+ "size" : "44x44",
+ "subtype" : "40mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-Launcher-41mm@2x.png",
+ "idiom" : "watch",
+ "role" : "appLauncher",
+ "scale" : "2x",
+ "size" : "46x46",
+ "subtype" : "41mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-Launcher-44mm@2x.png",
+ "idiom" : "watch",
+ "role" : "appLauncher",
+ "scale" : "2x",
+ "size" : "50x50",
+ "subtype" : "44mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-Launcher-45mm@2x.png",
+ "idiom" : "watch",
+ "role" : "appLauncher",
+ "scale" : "2x",
+ "size" : "51x51",
+ "subtype" : "45mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-Launcher-49mm@2x.png",
+ "idiom" : "watch",
+ "role" : "appLauncher",
+ "scale" : "2x",
+ "size" : "54x54",
+ "subtype" : "49mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-QuickLook-38mm@2x.png",
+ "idiom" : "watch",
+ "role" : "quickLook",
+ "scale" : "2x",
+ "size" : "86x86",
+ "subtype" : "38mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-QuickLook-42mm@2x.png",
+ "idiom" : "watch",
+ "role" : "quickLook",
+ "scale" : "2x",
+ "size" : "98x98",
+ "subtype" : "42mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-QuickLook-44mm@2x.png",
+ "idiom" : "watch",
+ "role" : "quickLook",
+ "scale" : "2x",
+ "size" : "108x108",
+ "subtype" : "44mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-QuickLook-45mm@2x.png",
+ "idiom" : "watch",
+ "role" : "quickLook",
+ "scale" : "2x",
+ "size" : "117x117",
+ "subtype" : "45mm"
+ },
+ {
+ "filename" : "AppIcon-Watch-QuickLook-49mm@2x.png",
+ "idiom" : "watch",
+ "role" : "quickLook",
+ "scale" : "2x",
+ "size" : "129x129",
+ "subtype" : "49mm"
+ },
+ {
+ "filename" : "AppIcon-WatchStore-1024.png",
+ "idiom" : "watch-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
],
"info" : {
"author" : "xcode",
diff --git a/Scripts/patch-xcodeproj.py b/Scripts/patch-xcodeproj.py
new file mode 100644
index 0000000..0e79a4e
--- /dev/null
+++ b/Scripts/patch-xcodeproj.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""Post-generation fix for XcodeGen issue #1549: XcodeGen does not link
+XCSwiftPackageProductDependency entries to local (path-based) Swift packages,
+which makes the Xcode GUI report "Missing package product". This script
+attaches the MartinOMEMO product dependencies to the vendored local package
+reference in the generated project file.
+
+Run after every `xcodegen generate` (see the Makefile `project` target).
+"""
+import sys
+
+PBXPROJ = "Luma.xcodeproj/project.pbxproj"
+
+
+def main() -> int:
+ with open(PBXPROJ, encoding="utf-8") as handle:
+ text = handle.read()
+
+ # Find the object id of the local package reference.
+ ref_id = None
+ for line in text.splitlines():
+ marker = " /* XCLocalSwiftPackageReference \"ThirdParty/MartinOMEMO\" */ = {"
+ if marker in line:
+ ref_id = line.split(" /*")[0].strip()
+ break
+ if ref_id is None:
+ print("patch-xcodeproj: local package reference not found; nothing to do")
+ return 0
+
+ needle = (
+ "/* MartinOMEMO */ = {\n"
+ "\t\t\tisa = XCSwiftPackageProductDependency;\n"
+ "\t\t\tproductName = MartinOMEMO;"
+ )
+ patch = (
+ "/* MartinOMEMO */ = {\n"
+ "\t\t\tisa = XCSwiftPackageProductDependency;\n"
+ "\t\t\tpackage = " + ref_id + " /* XCLocalSwiftPackageReference \"ThirdParty/MartinOMEMO\" */;\n"
+ "\t\t\tproductName = MartinOMEMO;"
+ )
+
+ count = text.count(needle)
+ if count == 0:
+ print("patch-xcodeproj: MartinOMEMO product dependencies already linked")
+ return 0
+ patched = text.replace(needle, patch)
+ with open(PBXPROJ, "w", encoding="utf-8") as handle:
+ handle.write(patched)
+ print("patch-xcodeproj: linked " + str(count) + " MartinOMEMO product dependencies")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/Scripts/regenerate-project.command b/Scripts/regenerate-project.command
index 2e9d5bb..a71511d 100755
--- a/Scripts/regenerate-project.command
+++ b/Scripts/regenerate-project.command
@@ -10,4 +10,7 @@ if ! command -v xcodegen >/dev/null; then
fi
xcodegen generate
+# XcodeGen issue #1549: link the vendored local package to its product
+# dependencies, otherwise the Xcode GUI reports "Missing package product".
+python3 Scripts/patch-xcodeproj.py
open Luma.xcodeproj
diff --git a/Scripts/verify.sh b/Scripts/verify.sh
index df7d01d..bf20eb7 100755
--- a/Scripts/verify.sh
+++ b/Scripts/verify.sh
@@ -18,12 +18,14 @@ required=(
Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift
Sources/Shared/Models/ArchiveSyncWorkBudget.swift
Sources/Shared/Models/ArchiveMessageBatchPolicy.swift
+ Sources/Shared/Models/OlderHistoryScrollPolicy.swift
Sources/Shared/Models/ChatScrollPositionPolicy.swift
Sources/Shared/Models/ChatTimelineEntry.swift
Sources/Shared/Models/ChatTypingPolicy.swift
Sources/Shared/Models/MessageReaction.swift
Sources/Shared/Models/MediaPickerSelectionPolicy.swift
Sources/Shared/Models/VideoNoteRecordingCompletionPolicy.swift
+ Sources/Shared/Models/VideoNoteRotationPolicy.swift
Sources/Shared/Models/VideoNoteRecordingLifecycle.swift
Sources/Shared/Models/MessageReplySwipePolicy.swift
Sources/Shared/Models/MessageReplyFallback.swift
@@ -66,6 +68,7 @@ required=(
Tests/MediaViewerDismissGestureTests.swift
Tests/ArchiveSyncCheckpointTests.swift
Tests/ArchiveSyncPaginationTests.swift
+ Tests/OlderHistoryScrollPolicyTests.swift
Tests/ArchiveSyncRecoveryPolicyTests.swift
Tests/ArchiveSyncWorkBudgetTests.swift
Tests/ArchiveMessageBatchPolicyTests.swift
@@ -77,9 +80,34 @@ required=(
Tests/MediaPickerSelectionPolicyTests.swift
Tests/VideoNoteRecordingCompletionPolicyTests.swift
Tests/VideoNoteRecordingLifecycleTests.swift
+ Tests/VideoNoteRotationPolicyTests.swift
Tests/VideoNoteStopPolicyTests.swift
Tests/MessageReplySwipeTests.swift
Tests/WatchVoiceMessageTests.swift
+ Tests/ArchiveStoreTests.swift
+ Tests/SASLprepTests.swift
+ Tests/SaslFailureMessageTests.swift
+ Tests/MediaPreviewProcessorVideoTests.swift
+ Tests/SCRAMSHA512Tests.swift
+ Tests/AppLockPolicyTests.swift
+ Tests/CallHistorySyncTests.swift
+ Tests/ReadStateSyncTests.swift
+ Tests/ChatMessageDeliveryTests.swift
+ UITests/TimelineUITests.swift
+ Sources/Shared/XMPP/LumaScramSha512Mechanism.swift
+ Sources/Shared/XMPP/CallHistorySync.swift
+ Sources/Shared/XMPP/ReadStateSync.swift
+ Sources/Shared/Security/AppLockVault.swift
+ Sources/Shared/Models/AppLockPolicy.swift
+ Sources/Shared/UI/AppLockView.swift
+ Sources/Shared/XMPP/SASLprep.swift
+ Sources/Shared/XMPP/LumaSaslFailureModule.swift
+ Sources/Shared/XMPP/SaslFailureMessage.swift
+ Sources/Shared/Models/ChatMessage.swift
+ Sources/Shared/Models/Conversation.swift
+ Sources/Shared/Persistence/ArchiveStore.swift
+ Sources/Shared/Persistence/ArchiveMetadataRecord.swift
+ Sources/Shared/Persistence/LegacyArchiveImporter.swift
Brand/LumaIcon-1024.png
Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json
Regenerate-Luma-Project.command
@@ -141,7 +169,7 @@ test "$(grep -c -- '- package: WebRTC' project.yml)" -eq 2 || {
exit 1
}
-if grep -R -q 'ChatBubbleShape\|maximumMediaSize\|25 МБ' Sources/Shared; then
+if grep -R -q 'ChatBubbleTailShape\|maximumMediaSize\|25 МБ' Sources/Shared; then
echo "Legacy bubble tails or the old client attachment limit are still present"
exit 1
fi
@@ -225,6 +253,10 @@ grep -q 'archiveWorkBudget.recordCompletedPage' Sources/Shared/XMPP/XMPPService.
echo "Incremental MAM passes must have a finite foreground work budget"
exit 1
}
+grep -q 'OlderHistoryScrollPolicy.page(' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "Interactive history paging must advance via the server's RSM first cursor"
+ exit 1
+}
if grep -q 'scheduleArchiveRestart\|restartDelayNanoseconds' Sources/Shared/XMPP/XMPPService.swift; then
echo "Failed MAM queries must not restart themselves indefinitely"
exit 1
@@ -249,12 +281,244 @@ grep -q 'applyArchiveBatch(mutations)' Sources/Shared/Models/AppModel.swift || {
echo "MAM mutations must be applied atomically before publishing SwiftUI state"
exit 1
}
-grep -q 'decodeSliceSize = 1' Sources/Shared/Models/ArchiveMessageBatchPolicy.swift || {
- echo "MAM decoding must remain limited to one stanza per main-actor slice"
+grep -q 'decodeSliceSize = 8' Sources/Shared/Models/ArchiveMessageBatchPolicy.swift || {
+ echo "MAM decoding must stay batched in small per-slice groups"
exit 1
}
-grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ChatArchive.swift || {
- echo "The durable archive snapshot must persist the XEP-0313 UID cursor"
+grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ArchiveStore.swift || {
+ echo "The durable archive store must persist the XEP-0313 UID cursor"
+ exit 1
+}
+if test -f Sources/Shared/Persistence/ChatArchive.swift; then
+ echo "The legacy JSON ChatArchive actor must stay removed"
+ exit 1
+fi
+grep -q '@Query' Sources/Shared/UI/MainTabView.swift || {
+ echo "Chat list must be SwiftData @Query-driven"
+ exit 1
+}
+grep -q 'NavigationSplitView' Sources/Shared/UI/MainTabView.swift || {
+ echo "macOS must keep the Telegram Desktop-style split view"
+ exit 1
+}
+grep -q 'SearchField' Sources/Shared/UI/MainTabView.swift || {
+ echo "Search fields must stay inline above the lists"
+ exit 1
+}
+grep -q 'TabView(selection:' Sources/Shared/UI/MainTabView.swift || {
+ echo "The iOS main screen must use the native TabView tab bar"
+ exit 1
+}
+grep -q 'toolbar(.hidden, for: .tabBar)' Sources/Shared/UI/ChatView.swift || {
+ echo "The tab bar must be hidden inside a pushed chat"
+ exit 1
+}
+grep -q 'PhoneWatchMessageData' Sources/Shared/Services/PhoneWatchBridge.swift || {
+ echo "The watch bridge must snapshot SwiftData models on the main actor"
+ exit 1
+}
+grep -q 'urn:xmpp:omemo:2' Sources/Shared/XMPP/LumaOMEMO2Module.swift || {
+ echo "OMEMO 2 must be implemented by the app-level module"
+ exit 1
+}
+grep -q 'decodeOmemo2OffMain' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "Incoming messages must route OMEMO 2 payloads to the OMEMO 2 module"
+ exit 1
+}
+grep -q 'XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO"' Luma.xcodeproj/project.pbxproj || {
+ echo "The vendored MartinOMEMO package must stay linked in the generated project (run make project)"
+ exit 1
+}
+grep -q '@Query' Sources/Shared/UI/ChatView.swift || {
+ echo "Chat timeline must be SwiftData @Query-driven"
+ exit 1
+}
+grep -q '@Query' Sources/Shared/UI/ForwardMessageView.swift || {
+ echo "Forward destination list must be SwiftData @Query-driven"
+ exit 1
+}
+grep -q '\.modelContext' Sources/Shared/UI/RootView.swift || {
+ echo "RootView must inject the SwiftData ModelContext"
+ exit 1
+}
+grep -q 'modelContext.delete(message)' Sources/Shared/Models/AppModel.swift || {
+ echo "Local message deletion must remove the SwiftData row"
+ exit 1
+}
+grep -q 'purgeLocallyDeletedMessages' Sources/Shared/Persistence/ArchiveStore.swift || {
+ echo "ArchiveStore must purge locally deleted rows for @Query views"
+ exit 1
+}
+grep -q 'completeUntilFirstUserAuthentication' Sources/Shared/Persistence/ArchiveStore.swift || {
+ echo "The SwiftData store must keep the data protection attribute"
+ exit 1
+}
+grep -q 'SASLprep' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "SCRAM passwords must be RFC 4013 (SASLprep) normalized"
+ exit 1
+}
+grep -q 'LumaSaslFailureModule' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "The raw SASL failure condition must be captured for the UI"
+ exit 1
+}
+grep -q 'Сервер отклонил имя пользователя или пароль' Sources/Shared/XMPP/SaslFailureMessage.swift || {
+ echo "SASL failures must produce an actionable Russian message"
+ exit 1
+}
+grep -q "doesn't match the one we calculated" Sources/Shared/XMPP/SaslFailureMessage.swift || {
+ echo "Prosody SCRAM proof mismatches must be explained in Russian"
+ exit 1
+}
+grep -q 'passwordVisible' Sources/Shared/UI/LoginView.swift || {
+ echo "The login screen must let the user verify the typed password"
+ exit 1
+}
+grep -q 'SCRAM-SHA-512' Sources/Shared/XMPP/LumaScramSha512Mechanism.swift || {
+ echo "The SCRAM-SHA-512 mechanism must be available"
+ exit 1
+}
+grep -q 'LumaScramSha512Mechanism(channelBindingStore: channelBindingStore)' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "SCRAM-SHA-512 must be registered ahead of Martin's mechanisms"
+ exit 1
+}
+grep -q 'NSFaceIDUsageDescription' project.yml || {
+ echo "Biometric unlock must declare its usage description"
+ exit 1
+}
+grep -q 'unlockWithBiometrics' Sources/Shared/Models/AppModel.swift || {
+ echo "The app lock must support biometric unlock"
+ exit 1
+}
+grep -q 'AppLockView' Sources/Shared/UI/RootView.swift || {
+ echo "The lock screen must cover every app layer"
+ exit 1
+}
+grep -q 'Блокировка приложения' Sources/Shared/UI/SettingsView.swift || {
+ echo "Settings must offer the app lock toggle"
+ exit 1
+}
+grep -q 'https://luma.chat/call-history' Sources/Shared/XMPP/CallHistorySync.swift || {
+ echo "Call history must sync through its service namespace"
+ exit 1
+}
+grep -q 'syncCallHistory' Sources/Shared/Models/AppModel.swift || {
+ echo "Call cards must be synced to the user's other devices"
+ exit 1
+}
+grep -q 'CallHistorySync.envelope' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "Incoming call-history payloads must be intercepted"
+ exit 1
+}
+grep -q 'https://luma.chat/read-marker' Sources/Shared/XMPP/ReadStateSync.swift || {
+ echo "Read state must sync through its service namespace"
+ exit 1
+}
+grep -q 'ReadStateSync.envelope' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "Incoming read-marker payloads must be intercepted"
+ exit 1
+}
+grep -q 'syncReadState' Sources/Shared/Models/AppModel.swift || {
+ echo "Read receipts must be synced to the user's other devices"
+ exit 1
+}
+grep -q 'delivery = \$0.delivery.merged(with: .delivered)' Sources/Shared/Models/AppModel.swift || {
+ echo "Delivery receipts must never downgrade an already read message"
+ exit 1
+}
+if grep -q 'message.body = Self.readMarkerBody' Sources/Shared/XMPP/XMPPService.swift; then
+ echo "Read-marker service stanzas must not carry a plaintext body (it leaks into self-chat on other clients)"
+ exit 1
+fi
+grep -q 'message.isMarkable = true' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "Outgoing 1:1 messages must be or peers never answer with "
+ exit 1
+}
+grep -q 'markersPublisher' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "XEP-0333 displayed markers must mark messages as read"
+ exit 1
+}
+grep -q 'markRead(at: index, cameFromPeer: false)' Sources/Shared/Models/AppModel.swift || {
+ echo "Self-chat must mark its own outgoing messages as read locally"
+ exit 1
+}
+grep -q 'case read(conversationJID' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "Read receipts must reference the conversation"
+ exit 1
+}
+grep -q 'merged(with: message.delivery)' Sources/Shared/Models/AppModel.swift || {
+ echo "MAM replays must not downgrade delivered or read state"
+ exit 1
+}
+grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || {
+ echo "Group chats must support local deletion"
+ exit 1
+}
+grep -q 'deletedGroupChatJIDs' Sources/Shared/Models/AppModel.swift || {
+ echo "Deleted group chats must stay suppressed for the session"
+ exit 1
+}
+grep -q 'Удалить чат' Sources/Shared/UI/GroupInfoView.swift || {
+ echo "Group info must offer deleting the chat"
+ exit 1
+}
+grep -q 'pendingGroupDeletion' Sources/Shared/UI/MainTabView.swift || {
+ echo "Chat list must offer deleting group chats"
+ exit 1
+}
+grep -q 'pickerResetToken' Sources/Shared/UI/ChatView.swift || {
+ echo "The photo picker must be recreated after each selection"
+ exit 1
+}
+grep -q 'let items = pickedMediaItems' Sources/Shared/UI/ChatView.swift || {
+ echo "Gallery staging must run on picker dismissal only"
+ exit 1
+}
+grep -q 'connectionStatus == .connected' Sources/Shared/Models/AppModel.swift || {
+ echo "Older-history loads must skip silently while disconnected"
+ exit 1
+}
+grep -q 'removeSessionWithOwnDeviceOnce' Sources/Shared/XMPP/LumaOMEMOStore.swift || {
+ echo "The self-session purge must be a one-time migration"
+ exit 1
+}
+grep -q 'ensureSelfSession' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "A missing self-session must be rebuilt from the own bundle"
+ exit 1
+}
+grep -q 'urn:xmpp:omemo:2' Sources/Shared/XMPP/XMPPService.swift || {
+ echo "OMEMO 2 payloads must be shown as undecryptable, not empty"
+ exit 1
+}
+grep -q 'biometricUnlockAvailable' Sources/Shared/Models/AppModel.swift || {
+ echo "Biometric availability must be cached off the view body"
+ exit 1
+}
+grep -q 'Звонки' Sources/Shared/UI/MainTabView.swift || {
+ echo "The main screen must offer the Calls tab"
+ exit 1
+}
+grep -q 'Настройки' Sources/Shared/UI/MainTabView.swift || {
+ echo "The main screen must offer the Settings tab"
+ exit 1
+}
+grep -q 'case .background:' Sources/App/LumaApp.swift || {
+ echo "The app lock must trigger on background only, not on inactive"
+ exit 1
+}
+grep -q 'onDismissRequest' Sources/Shared/UI/SystemPhotoCameraView.swift || {
+ echo "The camera picker must request dismissal before background preparation"
+ exit 1
+}
+grep -q 'onDismissRequest' Sources/Shared/UI/ChatView.swift || {
+ echo "The camera cover must dismiss through the SwiftUI binding"
+ exit 1
+}
+grep -q 'moveItem(at: sourceURL' Sources/Shared/UI/SystemPhotoCameraView.swift || {
+ echo "The picker movie must be claimed synchronously before dismissal cleanup"
+ exit 1
+}
+grep -q 'video-preview' Sources/Shared/UI/ChatView.swift || {
+ echo "The camera video path must log diagnostics"
exit 1
}
grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || {
@@ -318,7 +582,7 @@ grep -q 'MediaPickerSelectionPolicy.preferredOrder' Sources/Shared/UI/ChatView.s
echo "Ambiguous photo/video providers must retain a fallback representation"
exit 1
}
-grep -q 'item.loadTransferable(type: Data.self)' Sources/Shared/UI/ChatView.swift || {
+grep -q 'type: Data.self' Sources/Shared/UI/ChatView.swift || {
echo "PhotosPicker images need a data fallback when no file representation is available"
exit 1
}
@@ -358,7 +622,7 @@ grep -q 'scrollDismissesKeyboard(.interactively)' Sources/Shared/UI/ChatView.swi
echo "Interactive keyboard dismissal is missing"
exit 1
}
-grep -q 'selectedTimelineEntries' Sources/Shared/UI/ChatView.swift || {
+grep -q 'rebuildTimelineEntries' Sources/Shared/UI/ChatView.swift || {
echo "Chat rows and day boundaries must use the cached stable timeline"
exit 1
}
@@ -429,6 +693,10 @@ if grep -q 'Режим видеосообщения\|composerCaptureMode.*video'
echo "Video-note recording must not reuse the inline voice-message gesture"
exit 1
fi
+grep -q 'VideoNoteRotationPolicy.currentInterfaceOrientation' Sources/Shared/Services/VideoNoteRecorder.swift || {
+ echo "Video note capture must rotate by the interface orientation, not the device sensor"
+ exit 1
+}
grep -q 'resetCaptureGraph()' Sources/Shared/Services/VideoNoteRecorder.swift || {
echo "Reusable video-note capture cleanup is missing"
exit 1
@@ -537,8 +805,12 @@ grep -q 'urn:xmpp:reactions:0' Sources/Shared/XMPP/XMPPService.swift || {
echo "XEP-0444 capability advertisement is missing"
exit 1
}
-grep -q 'MessageReactionPolicy.quickChoices' Sources/Shared/UI/Components/MessageBubble.swift || {
- echo "Message reaction UI is missing"
+grep -q 'onReactPicker' Sources/Shared/UI/Components/MessageBubble.swift || {
+ echo "Message reaction picker is missing"
+ exit 1
+}
+grep -q 'EmojiPickerView' Sources/Shared/UI/ChatView.swift || {
+ echo "The emoji picker is not wired into the chat"
exit 1
}
grep -q 'ChatStateNotificationsModule' Sources/Shared/XMPP/XMPPService.swift || {
@@ -554,8 +826,8 @@ grep -q 'snapshotQueue.async' Sources/Shared/Services/PhoneWatchBridge.swift ||
exit 1
}
awk '
- /^#if os\(iOS\)$/ { ios_only = 1; next }
- /^#endif$/ && ios_only { ios_only = 0; next }
+ /^[[:space:]]*#if os\(iOS\)$/ { ios_only = 1; next }
+ /^[[:space:]]*#endif$/ && ios_only { ios_only = 0; next }
/availableVideoCodecTypes/ {
found = 1
if (!ios_only) escaped = 1
@@ -593,7 +865,7 @@ grep -q 'failedDrafts' Sources/Shared/UI/AttachmentPreviewView.swift || {
echo "Failed media drafts must remain available for retry"
exit 1
}
-grep -q 'rosterContactJIDs' Sources/Shared/UI/MainChatView.swift || {
+grep -q 'rosterContactJIDs' Sources/Shared/UI/MainTabView.swift || {
echo "Prosody roster contacts screen is missing"
exit 1
}
diff --git a/Sources/App/LumaApp.swift b/Sources/App/LumaApp.swift
index 99fb20e..35f487d 100644
--- a/Sources/App/LumaApp.swift
+++ b/Sources/App/LumaApp.swift
@@ -11,7 +11,20 @@ struct LumaApp: App {
RootView(model: model)
.tint(Color(red: 0.14, green: 0.56, blue: 0.96))
.onChange(of: scenePhase) { _, phase in
- model.setApplicationActive(phase == .active)
+ switch phase {
+ case .active:
+ model.setApplicationActive(true)
+ case .background:
+ model.setApplicationActive(false)
+ case .inactive:
+ // App switcher, Control Center and system pickers
+ // (photo gallery) make the scene briefly inactive;
+ // locking here would block the picker and the
+ // unlock screen would replace the app mid-flow.
+ break
+ @unknown default:
+ break
+ }
}
}
#if os(macOS)
diff --git a/Sources/Shared/Models/AccountConfiguration.swift b/Sources/Shared/Models/AccountConfiguration.swift
index d558660..429e575 100644
--- a/Sources/Shared/Models/AccountConfiguration.swift
+++ b/Sources/Shared/Models/AccountConfiguration.swift
@@ -22,7 +22,10 @@ struct AccountConfiguration: Codable, Equatable, Hashable, Identifiable, Sendabl
var effectiveResource: String {
let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty ? "Luma" : trimmed
+ if trimmed.isEmpty || trimmed == DeviceResource.legacyDefault {
+ return DeviceResource.default
+ }
+ return trimmed
}
init(
diff --git a/Sources/Shared/Models/AppLockPolicy.swift b/Sources/Shared/Models/AppLockPolicy.swift
new file mode 100644
index 0000000..77ff377
--- /dev/null
+++ b/Sources/Shared/Models/AppLockPolicy.swift
@@ -0,0 +1,12 @@
+import Foundation
+
+/// Pure policy for the app-lock passcode: minimum length and validation.
+/// Kept separate from the Keychain storage so it is unit-testable.
+enum AppLockPolicy {
+ static let minimumLength = 4
+
+ static func isValid(_ passcode: String) -> Bool {
+ passcode.count >= minimumLength
+ }
+}
+
diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift
index e23ca77..26ce1a8 100644
--- a/Sources/Shared/Models/AppModel.swift
+++ b/Sources/Shared/Models/AppModel.swift
@@ -1,13 +1,34 @@
-import Combine
import AVFoundation
+import Combine
import Foundation
+import LocalAuthentication
+import SwiftData
import UniformTypeIdentifiers
import WebRTC
+enum RuntimeEnvironment {
+ static var isRunningTests: Bool {
+ let environment = ProcessInfo.processInfo.environment
+ return environment["XCTestConfigurationFilePath"] != nil
+ || environment["XCTestBundlePath"] != nil
+ }
+
+ /// True inside Xcode preview canvases. The preview host launches the app
+ /// with `XCODE_RUNNING_FOR_PLAYGROUNDS=1` and shares the real app
+ /// container, so the model must not load the saved account or start the
+ /// XMPP/TLS stack there: the JIT executor interposes OpenSSL symbols onto
+ /// the system BoringSSL and any socket work crashes.
+ static var isRunningPreviews: Bool {
+ ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PLAYGROUNDS"] == "1"
+ }
+}
+
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var account: AccountConfiguration?
- @Published private(set) var connectionStatus: XMPPService.ConnectionStatus = .disconnected(reason: nil)
+ @Published private(set) var accounts: [AccountConfiguration] = []
+ @Published private(set) var connectionStatus: XMPPService.ConnectionStatus = .disconnected(
+ reason: nil)
private(set) var conversations: [Conversation] = [] {
willSet {
if !isApplyingArchiveBatch {
@@ -32,7 +53,10 @@ final class AppModel: ObservableObject {
@Published var selectedConversationID: String?
@Published private(set) var isOMEMOReady = false
@Published private(set) var ownFingerprint: String?
+ @Published private(set) var serverInformation: ServerInformation?
@Published private(set) var isArchiveSyncing = false
+ @Published private(set) var isLoadingOlderHistory = false
+ @Published private(set) var hasMoreOlderHistory = true
@Published private(set) var isAuthenticating = false
@Published private(set) var isSendingAttachment = false
@Published private(set) var isUpdatingAvatar = false
@@ -43,6 +67,14 @@ final class AppModel: ObservableObject {
@Published var errorMessage: String?
@Published var informationalMessage: String?
@Published var previewURL: URL?
+ @Published private(set) var isAppLocked = false
+ @Published private(set) var appLockIsEnabled = false
+ @Published private(set) var appLockBiometricIsEnabled = false
+ /// Cached once per session: LAContext.canEvaluatePolicy performs IPC with
+ /// the security daemon and must never run in a view body (it froze the
+ /// macOS lock screen).
+ @Published private(set) var biometricUnlockAvailable = false
+ @Published private(set) var biometricUnlockName = ""
@Published private(set) var mediaViewerItem: MediaViewerItem?
@Published private(set) var mediaPreviewURLs: [String: URL] = [:]
@Published private(set) var mediaThumbnailData: [String: Data] = [:]
@@ -55,18 +87,22 @@ final class AppModel: ObservableObject {
private let xmpp: XMPPService
private let credentials: CredentialVault
private let preferences: AccountPreferences
+ private let appLock = AppLockVault()
private let notifications: NotificationCoordinator
private let watchBridge: PhoneWatchBridge
private let avatarCache: AvatarCache
private let mediaPreviewProcessor = MediaPreviewProcessor()
private let mediaFileIO = MediaFileIO()
- private var archive: ChatArchive?
+ private var store: ArchiveStore?
+ private var storeAccountJID: String?
+ private var fallbackContext: ModelContext?
private var bootstrapTask: Task?
private var persistTask: Task?
private var watchSyncTask: Task?
private var isApplyingArchiveBatch = false
private var messageIndexByStorageKey: [String: Int] = [:]
private var messageIndexByStanzaKey: [String: Int] = [:]
+ private var messageIndexByOriginKey: [String: Int] = [:]
private var firstMessageIndexByID: [String: Int] = [:]
private var selectedMessagesCacheConversationID: String?
private var selectedMessagesCache: [ChatMessage] = []
@@ -78,14 +114,33 @@ final class AppModel: ObservableObject {
private var pendingRetractions: [String: XMPPService.RetractionEnvelope] = [:]
private var pendingReactions: [String: XMPPService.ReactionEnvelope] = [:]
private var correctionReceiptTargets: [String: String] = [:]
+
+ private struct PendingReadMarker {
+ var envelope: ReadStateSync.Envelope
+ var cameFromPeer: Bool
+ }
+ /// Read markers that arrived before the message they reference. MAM
+ /// replays newest stanzas first, so a read-marker is routinely applied
+ /// before its message on a fresh install; the marker waits here until the
+ /// message itself is upserted.
+ private var pendingReadMarkers: [String: PendingReadMarker] = [:]
+ private var lastDisplayedMarkerByConversation: [String: String] = [:]
private var localChatStateByConversation: [String: ChatTypingState] = [:]
private var localTypingPauseTasks: [String: Task] = [:]
private var remoteTypingExpiryTasks: [String: Task] = [:]
private var locallyDeletedMessageIDs: Set = []
private var lastSuccessfulMAMSync: Date?
private var lastSuccessfulMAMCursor: String?
+ private var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
+ private var hasMoreOlderHistoryByConversation: [String: Bool] = [:]
+ /// Server-side cursor (RSM of the last loaded page) for the next
+ /// interactive backward-history request, per conversation. The cursor
+ /// advances even when a page inserted nothing (reactions, duplicates), so
+ /// scrolling always moves toward genuinely older history.
+ private var olderHistoryCursorByConversation: [String: String] = [:]
private var pendingRoomPasswords: [String: String] = [:]
private var joiningRoomJIDs: Set = []
+ private var deletedGroupChatJIDs: Set = []
private var mediaSendActivity = MediaSendActivityTracker()
private var mediaPreparationTokens: Set = []
private var videoNoteCaptureIsActive = false
@@ -110,13 +165,24 @@ final class AppModel: ObservableObject {
self.watchBridge = watchBridge
self.avatarCache = avatarCache
+ // The app lock must be initialised before any view reads it; the
+ // locked state persists only in memory and is re-applied from the
+ // stored preferences on launch.
+ appLockIsEnabled = appLock.isEnabled
+ appLockBiometricIsEnabled = appLock.biometricUnlockEnabled
+ isAppLocked =
+ appLock.isEnabled
+ && !RuntimeEnvironment.isRunningTests
+ && !RuntimeEnvironment.isRunningPreviews
+ refreshBiometricAvailability()
+
xmpp.eventHandler = { [weak self] event in
self?.consume(event)
}
watchBridge.onReply = { [weak self] jid, text in
Task { @MainActor in
guard let self else { return }
- if !self.conversations.contains(where: { $0.id == jid.lowercased() }) {
+ if !self.conversations.contains(where: { $0.jid == jid.lowercased() }) {
self.upsertConversation(jid: jid, name: nil)
}
await self.sendText(text, to: jid)
@@ -131,7 +197,8 @@ final class AppModel: ObservableObject {
}
guard self.account != nil else {
- let error = "Войдите в XMPP-аккаунт на iPhone, затем повторите отправку с Apple Watch."
+ let error =
+ "Войдите в XMPP-аккаунт на iPhone, затем повторите отправку с Apple Watch."
self.errorMessage = error
self.watchBridge.reportVoiceResult(
transferID: voice.transferID,
@@ -155,7 +222,7 @@ final class AppModel: ObservableObject {
return
}
- if !self.conversations.contains(where: { $0.id == voice.jid.lowercased() }) {
+ if !self.conversations.contains(where: { $0.jid == voice.jid.lowercased() }) {
self.upsertConversation(jid: voice.jid, name: nil)
}
let messageID = await self.sendMedia(
@@ -168,7 +235,9 @@ final class AppModel: ObservableObject {
messageID: voice.stableMessageID
)
let success = messageID != nil
- let failure = success ? nil : (self.errorMessage ?? "Не удалось отправить голосовое сообщение.")
+ let failure =
+ success
+ ? nil : (self.errorMessage ?? "Не удалось отправить голосовое сообщение.")
if success {
self.informationalMessage = "Голосовое сообщение с Apple Watch отправлено."
} else if self.errorMessage == nil {
@@ -182,9 +251,11 @@ final class AppModel: ObservableObject {
}
}
- bootstrapTask = Task { [weak self] in
- guard let self else { return }
- await self.bootstrap()
+ if !RuntimeEnvironment.isRunningTests, !RuntimeEnvironment.isRunningPreviews {
+ bootstrapTask = Task { [weak self] in
+ guard let self else { return }
+ await self.bootstrap()
+ }
}
}
@@ -198,7 +269,7 @@ final class AppModel: ObservableObject {
var selectedConversation: Conversation? {
guard let selectedConversationID else { return nil }
- return conversations.first { $0.id == selectedConversationID }
+ return conversations.first { $0.jid == selectedConversationID }
}
private var durableArchiveSyncCheckpoint: ArchiveSyncCheckpoint? {
@@ -209,15 +280,26 @@ final class AppModel: ObservableObject {
)
}
+ /// Aggregated call history for the «Звонки» tab, newest first.
+ var callHistoryMessages: [ChatMessage] {
+ messages
+ .filter { $0.callHistory != nil }
+ .sorted { lhs, rhs in
+ if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
+ return lhs.clientID < rhs.clientID
+ }
+ }
+
var selectedMessages: [ChatMessage] {
guard let selectedConversationID else { return [] }
if selectedMessagesCacheConversationID == selectedConversationID {
return selectedMessagesCache
}
- let sorted = messages
+ let sorted =
+ messages
.filter { $0.conversationID == selectedConversationID }
.sorted { lhs, rhs in
- if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
+ if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
return lhs.timestamp < rhs.timestamp
}
selectedMessagesCacheConversationID = selectedConversationID
@@ -236,8 +318,45 @@ final class AppModel: ObservableObject {
return entries
}
+ /// The `ModelContext` injected into the SwiftUI environment so views
+ /// can read conversations and messages through `@Query`. Falls back to
+ /// an in-memory context when the on-disk store cannot be opened, keeping
+ /// the session usable without persistence.
+ var modelContext: ModelContext {
+ if let store { return store.context }
+ if let fallbackContext { return fallbackContext }
+ let schema = Schema([
+ Conversation.self,
+ ChatMessage.self,
+ ArchiveMetadata.self,
+ ])
+ let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
+ let container = try! ModelContainer(for: schema, configurations: [configuration])
+ let context = ModelContext(container)
+ fallbackContext = context
+ return context
+ }
+
+ /// Opens the per-account SwiftData store before `account` is published so
+ /// the `@Query`-backed screens never render without a usable context.
+ private func prepareStore(for jid: String) {
+ guard store == nil || storeAccountJID != jid else { return }
+ store = try? ArchiveStore(accountJID: jid)
+ storeAccountJID = jid
+ }
+
func bootstrap() async {
- guard let saved = preferences.load() else { return }
+#if DEBUG
+ if applyUITestChatIfRequested() { return }
+#endif
+ guard !RuntimeEnvironment.isRunningTests,
+ !RuntimeEnvironment.isRunningPreviews
+ else { return }
+ let configured = preferences.loadAccounts()
+ accounts = configured
+ if !configured.isEmpty { try? preferences.saveAccounts(configured) }
+ guard let saved = preferences.load() ?? configured.first else { return }
+ prepareStore(for: saved.normalizedJID)
account = saved
globalEncryptionEnabled = preferences.encryptionEnabled(for: saved.normalizedJID)
typingIndicatorsEnabled = preferences.chatStatesEnabled(for: saved.normalizedJID)
@@ -247,7 +366,8 @@ final class AppModel: ObservableObject {
do {
guard let storedPassword = try credentials.password(for: saved.normalizedJID) else {
account = nil
- archive = nil
+ store = nil
+ fallbackContext = nil
conversations = []
rosterContactJIDs = []
messages = []
@@ -258,7 +378,8 @@ final class AppModel: ObservableObject {
password = storedPassword
} catch {
account = nil
- archive = nil
+ store = nil
+ fallbackContext = nil
conversations = []
rosterContactJIDs = []
messages = []
@@ -270,7 +391,8 @@ final class AppModel: ObservableObject {
try await xmpp.connect(
account: saved,
password: password,
- archiveCheckpoint: durableArchiveSyncCheckpoint
+ archiveCheckpoint: durableArchiveSyncCheckpoint,
+ mamCheckpoints: mamCheckpoints
)
await notifications.requestAuthorization()
} catch {
@@ -297,9 +419,11 @@ final class AppModel: ObservableObject {
locallyDeletedMessageIDs = []
pendingRoomPasswords = [:]
joiningRoomJIDs = []
+ deletedGroupChatJIDs = []
rosterContactJIDs = []
resetMediaSendActivity()
resetMediaPreviews()
+ prepareStore(for: account.normalizedJID)
self.account = account
globalEncryptionEnabled = preferences.encryptionEnabled(for: account.normalizedJID)
typingIndicatorsEnabled = preferences.chatStatesEnabled(for: account.normalizedJID)
@@ -308,15 +432,19 @@ final class AppModel: ObservableObject {
try await xmpp.connect(
account: account,
password: password,
- archiveCheckpoint: durableArchiveSyncCheckpoint
+ archiveCheckpoint: durableArchiveSyncCheckpoint,
+ mamCheckpoints: mamCheckpoints
)
try credentials.save(password: password, for: account.normalizedJID)
+ accounts = accounts.filter { $0.normalizedJID != account.normalizedJID } + [account]
try preferences.save(account)
+ try preferences.saveAccounts(accounts)
await notifications.requestAuthorization()
} catch {
await xmpp.disconnect()
self.account = nil
- archive = nil
+ store = nil
+ fallbackContext = nil
conversations = []
rosterContactJIDs = []
messages = []
@@ -328,22 +456,27 @@ final class AppModel: ObservableObject {
func signOut(forgetHistory: Bool = false) async {
let oldAccount = account
await xmpp.disconnect()
- preferences.clear()
+ if let oldAccount {
+ accounts.removeAll { $0.normalizedJID == oldAccount.normalizedJID }
+ try? preferences.saveAccounts(accounts)
+ }
if let oldAccount {
try? credentials.deletePassword(for: oldAccount.normalizedJID)
if forgetHistory {
- try? await archive?.erase()
+ try? store?.erase()
}
}
persistTask?.cancel()
resetArchiveBatchState()
watchSyncTask?.cancel()
watchSyncTask = nil
- archive = nil
+ store = nil
+ fallbackContext = nil
account = nil
conversations = []
rosterContactJIDs = []
messages = []
+ deletedGroupChatJIDs = []
rebuildMessageIndex()
selectedConversationID = nil
isOMEMOReady = false
@@ -368,32 +501,223 @@ final class AppModel: ObservableObject {
resetTypingState()
resetMediaSendActivity()
resetMediaPreviews()
+ if let next = accounts.first {
+ await switchAccount(to: next)
+ }
}
- func reconnect() async {
- guard let account else { return }
+ /// Adds an account and makes it the active connection. The login screen
+ /// uses the regular sign-in path, so credentials and per-account archives
+ /// are handled exactly as they are for the first account.
+ func addAccount(account: AccountConfiguration, password: String) async {
+ await signIn(account: account, password: password)
+ }
+
+ func switchAccount(to target: AccountConfiguration) async {
+ guard target.normalizedJID != account?.normalizedJID else { return }
+ let password: String
do {
- guard let password = try credentials.password(for: account.normalizedJID) else {
- throw ReconnectError.passwordMissing
+ guard let stored = try credentials.password(for: target.normalizedJID) else {
+ errorMessage = "Для этого аккаунта нет пароля в Keychain. Войдите снова."
+ return
}
- try await xmpp.reconnectIfNeeded(password: password)
+ password = stored
+ } catch {
+ errorMessage = "Для этого аккаунта нет пароля в Keychain. Войдите снова."
+ return
+ }
+ await xmpp.disconnect()
+ prepareStore(for: target.normalizedJID)
+ account = target
+ try? preferences.save(target)
+ globalEncryptionEnabled = preferences.encryptionEnabled(for: target.normalizedJID)
+ typingIndicatorsEnabled = preferences.chatStatesEnabled(for: target.normalizedJID)
+ xmpp.setChatStatesEnabled(typingIndicatorsEnabled)
+ await loadArchive(for: target.normalizedJID)
+ do {
+ try await xmpp.connect(
+ account: target,
+ password: password,
+ archiveCheckpoint: durableArchiveSyncCheckpoint,
+ mamCheckpoints: mamCheckpoints
+ )
} catch {
errorMessage = error.localizedDescription
}
}
+ func reconnect() async {
+ guard !RuntimeEnvironment.isRunningTests else { return }
+ guard let account else { return }
+ do {
+ guard let password = try credentials.password(for: account.normalizedJID) else {
+ throw ReconnectError.passwordMissing
+ }
+ try await xmpp.reconnectIfNeeded(
+ password: password, archiveCheckpoint: durableArchiveSyncCheckpoint)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+#if DEBUG
+ /// UI-test mode: seeds an in-memory conversation with numbered messages
+ /// so XCUITests can exercise timeline scrolling and the reply swipe
+ /// without an XMPP server. Activated with the `-luma-ui-test-chat`
+ /// launch argument.
+ private func applyUITestChatIfRequested() -> Bool {
+ guard ProcessInfo.processInfo.arguments.contains("-luma-ui-test-chat") else {
+ return false
+ }
+ let account = AccountConfiguration(
+ jid: "uitest@example.org",
+ displayName: "UI Test",
+ resource: "uitest"
+ )
+ self.account = account
+ let conversation = Conversation(
+ jid: "peer@example.org",
+ displayName: "uitest-peer",
+ lastMessage: "Тестовое сообщение номер 60",
+ lastActivity: Date(),
+ kind: .direct
+ )
+ modelContext.insert(conversation)
+ conversations = [conversation]
+ let base = Date(timeIntervalSince1970: 1_700_000_000)
+ var seeded: [ChatMessage] = []
+ for index in 1...60 {
+ let outgoing = index.isMultiple(of: 2)
+ let message = ChatMessage(
+ id: "uitest-msg-\(index)",
+ conversationID: conversation.jid,
+ senderJID: outgoing ? account.normalizedJID : conversation.jid,
+ body: "Тестовое сообщение номер \(index)",
+ timestamp: base.addingTimeInterval(TimeInterval(index * 60)),
+ direction: outgoing ? .outgoing : .incoming,
+ delivery: .sent,
+ security: .plaintext,
+ kind: .text
+ )
+ modelContext.insert(message)
+ seeded.append(message)
+ }
+ messages = seeded
+ rebuildMessageIndex()
+ selectedConversationID = conversation.jid
+ return true
+ }
+#endif
+
func setApplicationActive(_ active: Bool) {
appIsActive = active
+ guard !RuntimeEnvironment.isRunningTests else { return }
xmpp.setApplicationActive(active)
if !active {
resetTypingState()
syncWatch(immediate: true)
+ if appLockIsEnabled {
+ isAppLocked = true
+ }
}
if active, case .disconnected(_) = connectionStatus {
Task { await reconnect() }
}
}
+ // MARK: - App lock
+
+ func unlockWithPasscode(_ passcode: String) -> Bool {
+ guard appLock.verify(passcode: passcode) else { return false }
+ isAppLocked = false
+ return true
+ }
+
+ func refreshBiometricAvailability() {
+ guard !RuntimeEnvironment.isRunningTests,
+ !RuntimeEnvironment.isRunningPreviews
+ else { return }
+ Task.detached(priority: .utility) {
+ let context = LAContext()
+ var error: NSError?
+ let available = context.canEvaluatePolicy(
+ .deviceOwnerAuthenticationWithBiometrics,
+ error: &error
+ )
+ let name = context.biometryType == .faceID ? "Face ID" : "Touch ID"
+ await MainActor.run { [weak self] in
+ self?.biometricUnlockAvailable = available
+ self?.biometricUnlockName = name
+ }
+ }
+ }
+
+ func unlockWithBiometrics() async -> Bool {
+ guard appLockIsEnabled, appLockBiometricIsEnabled else { return false }
+ let context = LAContext()
+ var error: NSError?
+ guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
+ else {
+ return false
+ }
+ do {
+ let granted = try await context.evaluatePolicy(
+ .deviceOwnerAuthenticationWithBiometrics,
+ localizedReason: "Разблокируйте Luma"
+ )
+ guard granted else { return false }
+ isAppLocked = false
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ func enableAppLock(passcode: String) -> Bool {
+ guard AppLockPolicy.isValid(passcode) else { return false }
+ do {
+ try appLock.save(passcode: passcode)
+ appLock.isEnabled = true
+ appLockIsEnabled = true
+ isAppLocked = false
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ func disableAppLock(passcode: String) -> Bool {
+ guard appLock.verify(passcode: passcode) else { return false }
+ try? appLock.deletePasscode()
+ appLock.isEnabled = false
+ appLock.biometricUnlockEnabled = false
+ appLockIsEnabled = false
+ appLockBiometricIsEnabled = false
+ isAppLocked = false
+ return true
+ }
+
+ func setAppLockBiometricUnlock(_ enabled: Bool, passcode: String) -> Bool {
+ guard appLock.verify(passcode: passcode) else { return false }
+ appLock.biometricUnlockEnabled = enabled
+ appLockBiometricIsEnabled = enabled
+ return true
+ }
+
+ func changeAppLockPasscode(from oldPasscode: String, to newPasscode: String) -> Bool {
+ guard AppLockPolicy.isValid(newPasscode), appLock.verify(passcode: oldPasscode) else {
+ return false
+ }
+ do {
+ try appLock.save(passcode: newPasscode)
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
func setVideoNoteCaptureActive(_ active: Bool) {
videoNoteCaptureIsActive = active
updateArchiveSyncSuspensionForMedia()
@@ -402,7 +726,7 @@ final class AppModel: ObservableObject {
func startCall(to rawJID: String, withVideo: Bool) async {
let jid = rawJID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !jid.isEmpty else { return }
- guard conversations.first(where: { $0.id == jid })?.isGroup != true else {
+ guard conversations.first(where: { $0.jid == jid })?.isGroup != true else {
errorMessage = "Звонки в текущем MVP доступны только в личных чатах."
return
}
@@ -466,9 +790,10 @@ final class AppModel: ObservableObject {
}
upsertConversation(jid: normalized, name: name)
selectedConversationID = normalized
- if let index = conversations.firstIndex(where: { $0.id == normalized }) {
+ if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
conversations[index].unreadCount = 0
}
+ sendDisplayedMarkerIfNeeded(for: normalized)
if addToRoster {
rosterContactJIDs.insert(normalized)
xmpp.addToRoster(jid: normalized, name: name)
@@ -481,11 +806,96 @@ final class AppModel: ObservableObject {
func selectConversation(id: String) {
let normalized = id.lowercased()
selectedConversationID = normalized
- if let index = conversations.firstIndex(where: { $0.id == normalized }) {
+ // Opening the chat marks it as read, so the unread badge disappears
+ // immediately and stays in sync with the watch.
+ if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
conversations[index].unreadCount = 0
}
+ sendDisplayedMarkerIfNeeded(for: normalized)
schedulePersist()
syncWatch()
+ if hasMoreOlderHistoryByConversation[normalized] == false,
+ selectedMessages.isEmpty
+ {
+ // An empty conversation must be allowed to retry a load after a
+ // previous attempt returned zero results. Otherwise it stays empty
+ // with `hasMore = false` and no way to trigger loading again.
+ hasMoreOlderHistoryByConversation[normalized] = true
+ }
+ hasMoreOlderHistory = hasMoreOlderHistoryByConversation[normalized] ?? true
+ }
+
+ /// Called when the chat view disappears (back to the list, another chat
+ /// selected, or a mode switch). Without this, `selectedConversationID`
+ /// would keep pointing at the last viewed chat forever, and incoming
+ /// messages for it would never increment the unread badge again.
+ func endConversationViewing(jid: String) {
+ let normalized = jid.lowercased()
+ if selectedConversationID == normalized {
+ selectedConversationID = nil
+ }
+ }
+
+ func loadOlderHistoryForSelectedConversation() {
+ // While disconnected the request would only fail with
+ // "not connected", which used to pop the global alert right over
+ // the timeline and blocked scrolling. Skip silently instead: the
+ // trigger re-fires once the connection is back.
+ guard !isLoadingOlderHistory,
+ hasMoreOlderHistory,
+ connectionStatus == .connected,
+ let conversation = selectedConversation else { return }
+ // let oldestServerID = selectedMessages
+ // .sorted { lhs, rhs in
+ // if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
+ // return lhs.clientID < rhs.clientID
+ // }
+ // .compactMap(\.stanzaID)
+ // .first
+ let oldestServerID = selectedMessages
+ .filter { $0.stanzaID != nil }
+ .min { lhs, rhs in
+ if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
+ return lhs.clientID < rhs.clientID
+ }?
+ .stanzaID
+
+ isLoadingOlderHistory = true
+ let conversationID = conversation.jid.lowercased()
+ // Prefer the cursor the server returned with the previous page: it
+ // always points at genuinely older history. Fall back to the oldest
+ // local stanza id only when no server cursor is known yet (freshly
+ // opened conversation in this session).
+ let before =
+ olderHistoryCursorByConversation[conversationID]
+ ?? oldestServerID
+ xmpp.loadOlderHistory(
+ conversationJID: conversation.jid,
+ isGroup: conversation.isGroup,
+ before: before
+ ) { [weak self] result in
+ guard let self else { return }
+ self.isLoadingOlderHistory = false
+ switch result {
+ case .success(let page):
+ if let nextBefore = page.nextBefore {
+ self.olderHistoryCursorByConversation[conversationID] =
+ nextBefore
+ } else {
+ // Exhausted or the server stopped echoing RSM: never
+ // re-request the same page in a loop.
+ self.olderHistoryCursorByConversation[conversationID] = nil
+ }
+ self.hasMoreOlderHistoryByConversation[conversationID] =
+ page.hasMore
+ if self.selectedConversationID == conversationID {
+ self.hasMoreOlderHistory = page.hasMore
+ }
+ case .failure(let error):
+ // Keep the cursor: the next scroll retries the same page.
+ self.errorMessage = error.localizedDescription
+ }
+ }
}
func createOrJoinGroup(
@@ -504,10 +914,12 @@ final class AppModel: ObservableObject {
errorMessage = "Введите псевдоним для группового чата."
return
}
- let validInvitees = invitees
+ let validInvitees =
+ invitees
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter(Self.isValidJID)
+ deletedGroupChatJIDs.remove(roomJID)
upsertGroupConversation(
jid: roomJID,
name: name,
@@ -524,7 +936,7 @@ final class AppModel: ObservableObject {
informationalMessage = "Приглашения отправлены: \(validInvitees.count)."
}
} catch {
- if let index = conversations.firstIndex(where: { $0.id == roomJID }) {
+ if let index = conversations.firstIndex(where: { $0.jid == roomJID }) {
conversations[index].isGroupJoined = false
}
errorMessage = error.localizedDescription
@@ -534,9 +946,12 @@ final class AppModel: ObservableObject {
func joinGroup(jid rawJID: String, nickname preferredNickname: String? = nil) async {
let jid = rawJID.lowercased()
- guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else { return }
+ guard let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else {
+ return
+ }
guard !conversations[index].isGroupJoined,
- joiningRoomJIDs.insert(jid).inserted else { return }
+ joiningRoomJIDs.insert(jid).inserted
+ else { return }
defer { joiningRoomJIDs.remove(jid) }
let trimmedNickname = preferredNickname?.trimmingCharacters(in: .whitespacesAndNewlines)
let nickname: String
@@ -565,15 +980,77 @@ final class AppModel: ObservableObject {
func leaveGroup(jid rawJID: String) {
let jid = rawJID.lowercased()
xmpp.leaveRoom(roomJID: jid)
- guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else { return }
+ guard let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else {
+ return
+ }
conversations[index].isGroupJoined = false
conversations[index].shouldAutojoin = false
conversations[index].occupantCount = 0
schedulePersist()
}
+ /// Leaves the room on the server (when joined) and removes the chat
+ /// together with its local history from this device.
+ func deleteGroupChat(jid rawJID: String) {
+ let jid = rawJID.lowercased()
+ // Suppress re-creation first: leaveRoom fires a synchronous
+ // roomState event, and the server-side state transition would
+ // otherwise resurrect the conversation later in this session.
+ deletedGroupChatJIDs.insert(jid)
+ let wasJoined =
+ conversations.first(where: { $0.jid == jid && $0.isGroup })?.isGroupJoined
+ == true
+ if wasJoined {
+ xmpp.leaveRoom(roomJID: jid)
+ }
+ removeGroupChatLocally(jid: jid)
+ informationalMessage =
+ wasJoined
+ ? "Вы вышли из комнаты, чат удалён с этого устройства."
+ : "Групповой чат удалён с этого устройства."
+ }
+
+ private func removeGroupChatLocally(jid: String) {
+ let normalized = jid.lowercased()
+ if let conversation = conversations.first(where: { $0.jid == normalized }) {
+ modelContext.delete(conversation)
+ }
+ conversations.removeAll { $0.jid == normalized }
+
+ let removedMessages = messages.filter { $0.conversationID == normalized }
+ for message in removedMessages {
+ modelContext.delete(message)
+ removeCachedMedia(for: message.clientID)
+ }
+ messages.removeAll { $0.conversationID == normalized }
+ rebuildMessageIndex()
+
+ let deletionKeyPrefix = normalized + "\u{1F}"
+ locallyDeletedMessageIDs = locallyDeletedMessageIDs.filter {
+ !$0.hasPrefix(deletionKeyPrefix)
+ }
+ pendingCorrections = pendingCorrections.filter { !$0.key.hasPrefix(deletionKeyPrefix) }
+ pendingRetractions = pendingRetractions.filter { !$0.key.hasPrefix(deletionKeyPrefix) }
+ pendingReactions = pendingReactions.filter { !$0.key.hasPrefix(normalized + "|") }
+ hasMoreOlderHistoryByConversation.removeValue(forKey: normalized)
+ olderHistoryCursorByConversation.removeValue(forKey: normalized)
+ pendingRoomPasswords.removeValue(forKey: normalized)
+ joiningRoomJIDs.remove(normalized)
+ localChatStateByConversation.removeValue(forKey: normalized)
+ localTypingPauseTasks.removeValue(forKey: normalized)?.cancel()
+ remoteTypingExpiryTasks.removeValue(forKey: normalized)?.cancel()
+ typingParticipantsByConversation.removeValue(forKey: normalized)
+ if selectedConversationID == normalized {
+ selectedConversationID = nil
+ }
+ sortConversations()
+ schedulePersist()
+ syncWatch()
+ }
+
func inviteMembers(_ rawJIDs: [String], to roomJID: String) async {
- let jids = rawJIDs
+ let jids =
+ rawJIDs
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter(Self.isValidJID)
guard !jids.isEmpty else {
@@ -597,14 +1074,16 @@ final class AppModel: ObservableObject {
) async {
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty,
- let account,
- let peer = explicitJID ?? selectedConversation?.jid else { return }
- let conversation = conversations.first { $0.id == peer.lowercased() }
+ let account,
+ let peer = explicitJID ?? selectedConversation?.jid
+ else { return }
+ let conversation = conversations.first { $0.jid == peer.lowercased() }
let isGroup = conversation?.isGroup == true
let encrypted = encryptionEnabled(for: peer)
let replyTarget = replyingTo.flatMap { message -> ChatMessage? in
guard message.canBeRepliedTo,
- message.conversationID == peer.lowercased() else { return nil }
+ message.conversationID == peer.lowercased()
+ else { return nil }
return message
}
@@ -636,17 +1115,21 @@ final class AppModel: ObservableObject {
return XMPPService.ReplyReference(
id: replyID,
authorJID: target.senderJID,
- fallbackAuthor: target.senderDisplayName ?? displayName(for: target.senderJID),
+ fallbackAuthor: target.senderDisplayName
+ ?? displayName(for: target.senderJID),
preview: target.quotePreview
)
},
isGroup: isGroup
)
updateMessage(id: id) {
- $0.delivery = .sent
+ $0.delivery = $0.delivery.merged(with: .sent)
$0.encryptionFingerprint = fingerprint
}
let conversationID = peer.lowercased()
+ if conversationID == account.normalizedJID {
+ markMessageAsReadIfOutgoing(id: id)
+ }
localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil
localChatStateByConversation[conversationID] = .active
@@ -660,8 +1143,9 @@ final class AppModel: ObservableObject {
func sendLocation(_ location: GeoLocation, to explicitJID: String? = nil) async {
guard let account,
- let peer = explicitJID ?? selectedConversation?.jid else { return }
- let conversation = conversations.first { $0.id == peer.lowercased() }
+ let peer = explicitJID ?? selectedConversation?.jid
+ else { return }
+ let conversation = conversations.first { $0.jid == peer.lowercased() }
let isGroup = conversation?.isGroup == true
let encrypted = encryptionEnabled(for: peer)
let id = UUID().uuidString
@@ -688,9 +1172,12 @@ final class AppModel: ObservableObject {
isGroup: isGroup
)
updateMessage(id: id) {
- $0.delivery = .sent
+ $0.delivery = $0.delivery.merged(with: .sent)
$0.encryptionFingerprint = fingerprint
}
+ if peer.lowercased() == account.normalizedJID {
+ markMessageAsReadIfOutgoing(id: id)
+ }
} catch {
updateMessage(id: id) { $0.delivery = .failed }
errorMessage = error.localizedDescription
@@ -702,8 +1189,9 @@ final class AppModel: ObservableObject {
func editMessage(id: String, newBody rawText: String) async {
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty,
- let index = messages.firstIndex(where: { $0.id == id }),
- messages[index].canBeEdited else { return }
+ let index = messages.firstIndex(where: { $0.clientID == id }),
+ messages[index].canBeEdited
+ else { return }
let previous = messages[index]
guard text != previous.body else { return }
@@ -712,7 +1200,8 @@ final class AppModel: ObservableObject {
let encrypted = previous.security == .omemo
correctionReceiptTargets[correctionID] = id
if correctionReceiptTargets.count > 100,
- let staleID = correctionReceiptTargets.keys.first(where: { $0 != correctionID }) {
+ let staleID = correctionReceiptTargets.keys.first(where: { $0 != correctionID })
+ {
correctionReceiptTargets.removeValue(forKey: staleID)
}
updateMessage(id: id) {
@@ -727,16 +1216,20 @@ final class AppModel: ObservableObject {
to: previous.conversationID,
messageID: correctionID,
encrypted: encrypted,
- replacingMessageID: previous.id
+ replacingMessageID: previous.clientID
)
updateMessage(id: id) {
- $0.delivery = .sent
+ $0.delivery = $0.delivery.merged(with: .sent)
$0.encryptionFingerprint = fingerprint
}
+ if previous.conversationID == account?.normalizedJID {
+ markMessageAsReadIfOutgoing(id: id)
+ }
} catch {
correctionReceiptTargets.removeValue(forKey: correctionID)
- if let currentIndex = messages.firstIndex(where: { $0.id == id }),
- messages[currentIndex].delivery == .sending {
+ if let currentIndex = messages.firstIndex(where: { $0.clientID == id }),
+ messages[currentIndex].delivery == .sending
+ {
messages[currentIndex] = previous
updateConversationPreview(for: previous, incrementUnread: false)
}
@@ -748,25 +1241,27 @@ final class AppModel: ObservableObject {
func retractMessage(id: String) async {
guard let account,
- let message = messages.first(where: { $0.id == id }),
- message.canBeRetracted else { return }
+ let message = messages.first(where: { $0.clientID == id }),
+ message.canBeRetracted
+ else { return }
let retractionID = UUID().uuidString
do {
try await xmpp.sendRetraction(
to: message.conversationID,
- targetID: message.id,
+ targetID: message.clientID,
retractionID: retractionID,
encrypted: message.security == .omemo
)
- applyRetraction(XMPPService.RetractionEnvelope(
- peerJID: message.conversationID,
- senderJID: account.normalizedJID,
- targetID: message.id,
- retractionID: retractionID,
- timestamp: Date(),
- isOutgoing: true
- ))
+ applyRetraction(
+ XMPPService.RetractionEnvelope(
+ peerJID: message.conversationID,
+ senderJID: account.normalizedJID,
+ targetID: message.clientID,
+ retractionID: retractionID,
+ timestamp: Date(),
+ isOutgoing: true
+ ))
} catch {
errorMessage = error.localizedDescription
}
@@ -782,19 +1277,22 @@ final class AppModel: ObservableObject {
guard !ids.isEmpty else { return }
let normalizedConversationID = conversationID?.lowercased()
let selected = messages.filter { message in
- ids.contains(message.id)
- && (normalizedConversationID == nil || message.conversationID == normalizedConversationID)
+ ids.contains(message.clientID)
+ && (normalizedConversationID == nil
+ || message.conversationID == normalizedConversationID)
}
guard !selected.isEmpty else { return }
let affectedConversations = Set(selected.map(\.conversationID))
for message in selected {
- locallyDeletedMessageIDs.insert(Self.localDeletionKey(
- messageID: message.id,
- conversationID: message.conversationID
- ))
+ modelContext.delete(message)
+ locallyDeletedMessageIDs.insert(
+ Self.localDeletionKey(
+ messageID: message.clientID,
+ conversationID: message.conversationID
+ ))
let pendingKey = Self.localDeletionKey(
- messageID: message.id,
+ messageID: message.clientID,
conversationID: message.conversationID
)
pendingCorrections.removeValue(forKey: pendingKey)
@@ -803,22 +1301,29 @@ final class AppModel: ObservableObject {
guard envelope.peerJID.lowercased() == message.conversationID else {
return true
}
- if envelope.targetID == message.id { return false }
+ if envelope.targetID == message.clientID { return false }
if let stanzaID = message.stanzaID,
- envelope.targetID == stanzaID { return false }
+ envelope.targetID == stanzaID
+ {
+ return false
+ }
return true
}
- removeCachedMedia(for: message.id)
+ removeCachedMedia(for: message.clientID)
}
- let selectedKeys = Set(selected.map { Self.localDeletionKey(
- messageID: $0.id,
- conversationID: $0.conversationID
- ) })
+ let selectedKeys = Set(
+ selected.map {
+ Self.localDeletionKey(
+ messageID: $0.clientID,
+ conversationID: $0.conversationID
+ )
+ })
messages.removeAll { message in
- selectedKeys.contains(Self.localDeletionKey(
- messageID: message.id,
- conversationID: message.conversationID
- ))
+ selectedKeys.contains(
+ Self.localDeletionKey(
+ messageID: message.clientID,
+ conversationID: message.conversationID
+ ))
}
rebuildMessageIndex()
for conversationID in affectedConversations {
@@ -835,13 +1340,15 @@ final class AppModel: ObservableObject {
@discardableResult
func forwardMessages(_ selectedMessages: [ChatMessage], to rawRecipient: String) async -> Bool {
- let recipient = rawRecipient
+ let recipient =
+ rawRecipient
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
- let forwardableMessages = selectedMessages
+ let forwardableMessages =
+ selectedMessages
.filter(\.canBeForwarded)
.sorted { lhs, rhs in
- if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
+ if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
return lhs.timestamp < rhs.timestamp
}
guard !forwardableMessages.isEmpty else { return false }
@@ -857,7 +1364,8 @@ final class AppModel: ObservableObject {
for message in forwardableMessages {
guard await forwardMessageItem(message, to: recipient) else {
if forwardedCount > 0 {
- informationalMessage = "Переслано сообщений: \(forwardedCount) из \(forwardableMessages.count)."
+ informationalMessage =
+ "Переслано сообщений: \(forwardedCount) из \(forwardableMessages.count)."
}
return false
}
@@ -870,7 +1378,8 @@ final class AppModel: ObservableObject {
}
private func forwardMessageItem(_ message: ChatMessage, to recipient: String) async -> Bool {
- let attribution = "↪ Переслано от \(message.senderDisplayName ?? displayName(for: message.senderJID))"
+ let attribution =
+ "↪ Переслано от \(message.senderDisplayName ?? displayName(for: message.senderJID))"
switch message.kind {
case .text:
@@ -888,7 +1397,7 @@ final class AppModel: ObservableObject {
case .attachment, .photo, .video, .audio, .voice, .videoNote:
do {
let url: URL
- if let cached = mediaPreviewURLs[message.id] {
+ if let cached = mediaPreviewURLs[message.clientID] {
url = cached
} else {
url = try await localAttachmentURL(
@@ -923,17 +1432,20 @@ final class AppModel: ObservableObject {
func message(withID id: String, in conversationID: String? = nil) -> ChatMessage? {
guard let conversationID else {
if let index = firstMessageIndexByID[id],
- messages.indices.contains(index),
- messages[index].id == id {
+ messages.indices.contains(index),
+ messages[index].clientID == id
+ {
return messages[index]
}
return messages.first { $0.stanzaID == id }
}
let normalized = conversationID.lowercased()
- guard let index = messageIndex(
- referenceID: id,
- conversationID: normalized
- ) else { return nil }
+ guard
+ let index = messageIndex(
+ referenceID: id,
+ conversationID: normalized
+ )
+ else { return nil }
return messages[index]
}
@@ -994,7 +1506,8 @@ final class AppModel: ObservableObject {
thumbnailData preparedThumbnailData: Data? = nil
) async -> String? {
guard let account,
- let peer = explicitJID ?? selectedConversation?.jid else { return nil }
+ let peer = explicitJID ?? selectedConversation?.jid
+ else { return nil }
errorMessage = nil
if connectionStatus != .connected {
await reconnect()
@@ -1004,7 +1517,7 @@ final class AppModel: ObservableObject {
let id = explicitMessageID ?? UUID().uuidString
let filename = Self.safeFilename(rawFilename)
let kind = rawKind.isMedia ? rawKind : .attachment
- let conversation = conversations.first { $0.id == peer.lowercased() }
+ let conversation = conversations.first { $0.jid == peer.lowercased() }
let isGroup = conversation?.isGroup == true
let encrypted = encryptionEnabled(for: peer)
var duration = suppliedDuration
@@ -1059,10 +1572,13 @@ final class AppModel: ObservableObject {
isGroup: isGroup
)
updateMessage(id: id) {
- $0.delivery = .sent
+ $0.delivery = $0.delivery.merged(with: .sent)
$0.remoteAttachmentURL = result.remoteURL
$0.encryptionFingerprint = result.fingerprint
}
+ if peer.lowercased() == account.normalizedJID {
+ markMessageAsReadIfOutgoing(id: id)
+ }
sentID = id
} catch {
if didInsertPending {
@@ -1080,12 +1596,13 @@ final class AppModel: ObservableObject {
&& message.delivery == .failed
&& message.kind.isMedia
&& message.remoteAttachmentURL == nil
- && mediaPreviewURLs[message.id] != nil
+ && mediaPreviewURLs[message.clientID] != nil
}
func retryMediaMessage(_ message: ChatMessage) async {
guard canRetryMediaMessage(message),
- let localURL = mediaPreviewURLs[message.id] else { return }
+ let localURL = mediaPreviewURLs[message.clientID]
+ else { return }
let activityToken = beginMediaSendActivity()
defer { endMediaSendActivity(activityToken) }
errorMessage = nil
@@ -1099,7 +1616,7 @@ final class AppModel: ObservableObject {
from: localURL,
preferredKind: message.kind
).data
- updateMessage(id: message.id) { $0.delivery = .sending }
+ updateMessage(id: message.clientID) { $0.delivery = .sending }
let result = try await uploadAndSendMediaWithRetry(
data: data,
filename: message.localFilename ?? localURL.lastPathComponent,
@@ -1107,17 +1624,20 @@ final class AppModel: ObservableObject {
kind: message.kind,
duration: message.duration,
to: message.conversationID,
- messageID: message.id,
+ messageID: message.clientID,
encrypted: message.security == .omemo,
isGroup: message.isGroupMessage
)
- updateMessage(id: message.id) {
- $0.delivery = .sent
+ updateMessage(id: message.clientID) {
+ $0.delivery = $0.delivery.merged(with: .sent)
$0.remoteAttachmentURL = result.remoteURL
$0.encryptionFingerprint = result.fingerprint
}
+ if message.conversationID == account?.normalizedJID {
+ markMessageAsReadIfOutgoing(id: message.clientID)
+ }
} catch {
- updateMessage(id: message.id) { $0.delivery = .failed }
+ updateMessage(id: message.clientID) { $0.delivery = .failed }
errorMessage = error.localizedDescription
}
schedulePersist()
@@ -1139,7 +1659,8 @@ final class AppModel: ObservableObject {
do {
let staged = try await mediaFileIO.stageAttachmentDraft(from: source)
let filename = Self.safeFilename(staged.filename)
- let contentType = staged.contentTypeIdentifier.flatMap { UTType($0) }
+ let contentType =
+ staged.contentTypeIdentifier.flatMap { UTType($0) }
?? UTType(filenameExtension: source.pathExtension)
let kind = preferredKind ?? Self.mediaKind(for: contentType, filename: filename)
let mimeType = contentType?.preferredMIMEType ?? "application/octet-stream"
@@ -1148,15 +1669,16 @@ final class AppModel: ObservableObject {
kind: kind,
mimeType: mimeType
)
- drafts.append(AttachmentDraft(
- url: staged.url,
- filename: filename,
- mimeType: mimeType,
- kind: kind,
- duration: analysis.duration,
- byteCount: staged.byteCount,
- thumbnailData: analysis.thumbnailData
- ))
+ drafts.append(
+ AttachmentDraft(
+ url: staged.url,
+ filename: filename,
+ mimeType: mimeType,
+ kind: kind,
+ duration: analysis.duration,
+ byteCount: staged.byteCount,
+ thumbnailData: analysis.thumbnailData
+ ))
} catch {
lastPreparationError = error
}
@@ -1250,7 +1772,7 @@ final class AppModel: ObservableObject {
func previewAttachment(_ message: ChatMessage) async {
do {
let output: URL
- if let cached = mediaPreviewURLs[message.id] {
+ if let cached = mediaPreviewURLs[message.clientID] {
output = cached
} else {
guard message.remoteAttachmentURL != nil else { return }
@@ -1263,34 +1785,36 @@ final class AppModel: ObservableObject {
}
func mediaPreviewURL(for message: ChatMessage) -> URL? {
- mediaPreviewURLs[message.id]
+ mediaPreviewURLs[message.clientID]
}
func mediaThumbnail(for message: ChatMessage) -> Data? {
- mediaThumbnailData[message.id]
+ mediaThumbnailData[message.clientID]
}
func audioWaveform(for message: ChatMessage) -> [Float] {
- audioWaveformSamples[message.id] ?? MediaPreviewProcessor.placeholderWaveform
+ audioWaveformSamples[message.clientID] ?? MediaPreviewProcessor.placeholderWaveform
}
func isMediaPreviewLoading(_ message: ChatMessage) -> Bool {
- loadingMediaIDs.contains(message.id)
+ loadingMediaIDs.contains(message.clientID)
}
func prepareMediaPreview(_ message: ChatMessage) async {
guard Self.supportsInlinePreview(message.kind),
- message.remoteAttachmentURL != nil,
- mediaPreviewURLs[message.id] == nil,
- loadingMediaIDs.insert(message.id).inserted else { return }
- defer { loadingMediaIDs.remove(message.id) }
+ message.remoteAttachmentURL != nil,
+ mediaPreviewURLs[message.clientID] == nil,
+ loadingMediaIDs.insert(message.clientID).inserted
+ else { return }
+ defer { loadingMediaIDs.remove(message.clientID) }
do {
- let output = try await localAttachmentURL(for: message, directoryName: "LumaMediaPreviews")
- mediaPreviewURLs[message.id] = output
+ let output = try await localAttachmentURL(
+ for: message, directoryName: "LumaMediaPreviews")
+ mediaPreviewURLs[message.clientID] = output
await analyzeMediaPreview(
at: output,
- messageID: message.id,
+ messageID: message.clientID,
kind: message.kind,
mimeType: message.mimeType
)
@@ -1305,20 +1829,20 @@ final class AppModel: ObservableObject {
do {
let output: URL
- if let cached = mediaPreviewURLs[message.id] {
+ if let cached = mediaPreviewURLs[message.clientID] {
output = cached
} else {
guard message.remoteAttachmentURL != nil else { return }
- loadingMediaIDs.insert(message.id)
- defer { loadingMediaIDs.remove(message.id) }
+ loadingMediaIDs.insert(message.clientID)
+ defer { loadingMediaIDs.remove(message.clientID) }
output = try await localAttachmentURL(
for: message,
directoryName: "LumaMediaPreviews"
)
- mediaPreviewURLs[message.id] = output
+ mediaPreviewURLs[message.clientID] = output
await analyzeMediaPreview(
at: output,
- messageID: message.id,
+ messageID: message.clientID,
kind: message.kind,
mimeType: message.mimeType
)
@@ -1327,10 +1851,10 @@ final class AppModel: ObservableObject {
audioPlayback.stop()
NotificationCenter.default.post(
name: .lumaExclusiveMediaPlayback,
- object: message.id
+ object: message.clientID
)
mediaViewerItem = MediaViewerItem(
- id: message.id,
+ id: message.clientID,
url: output,
kind: message.kind,
title: message.localFilename ?? (message.kind == .photo ? "Фото" : "Видео")
@@ -1350,7 +1874,7 @@ final class AppModel: ObservableObject {
func encryptionPreference(for jid: String) -> EncryptionPreference {
conversations
- .first(where: { $0.id == jid.lowercased() })?
+ .first(where: { $0.jid == jid.lowercased() })?
.encryptionPreference ?? .inheritGlobal
}
@@ -1373,13 +1897,14 @@ final class AppModel: ObservableObject {
}
func typingText(for conversation: Conversation) -> String? {
- let names = typingParticipantsByConversation[conversation.id.lowercased()]
+ let names =
+ typingParticipantsByConversation[conversation.jid.lowercased()]
.map { Array($0.values) } ?? []
return ChatTypingPolicy.displayText(names: names, isGroup: conversation.isGroup)
}
func updateComposerActivity(_ text: String, in conversation: Conversation) {
- let conversationID = conversation.id.lowercased()
+ let conversationID = conversation.jid.lowercased()
localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil
guard typingIndicatorsEnabled, appIsActive else { return }
@@ -1398,14 +1923,15 @@ final class AppModel: ObservableObject {
return
}
guard let self,
- self.localChatStateByConversation[conversationID] == .composing else { return }
+ self.localChatStateByConversation[conversationID] == .composing
+ else { return }
self.transitionLocalChatState(.paused, in: conversation)
self.localTypingPauseTasks[conversationID] = nil
}
}
func endComposerActivity(in conversation: Conversation) {
- let conversationID = conversation.id.lowercased()
+ let conversationID = conversation.jid.lowercased()
localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil
transitionLocalChatState(.inactive, in: conversation)
@@ -1413,13 +1939,14 @@ final class AppModel: ObservableObject {
func toggleReaction(_ emoji: String, on sourceMessage: ChatMessage) async {
guard let account,
- let message = message(
- withID: sourceMessage.id,
- in: sourceMessage.conversationID
- ),
- message.canBeReactedTo,
- let targetID = message.reactionIdentifier,
- let selectedEmoji = MessageReactionPolicy.sanitized([emoji]).first else { return }
+ let message = message(
+ withID: sourceMessage.clientID,
+ in: sourceMessage.conversationID
+ ),
+ message.canBeReactedTo,
+ let targetID = message.reactionIdentifier,
+ let selectedEmoji = MessageReactionPolicy.sanitized([emoji]).first
+ else { return }
let previous = message.reactionEmojis(from: account.normalizedJID)
var updated = previous
@@ -1467,7 +1994,9 @@ final class AppModel: ObservableObject {
}
func setEncryptionPreference(_ preference: EncryptionPreference, for jid: String) {
- guard let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) else { return }
+ guard let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) else {
+ return
+ }
conversations[index].encryptionPreference = preference
schedulePersist()
syncWatch()
@@ -1501,10 +2030,23 @@ final class AppModel: ObservableObject {
objectWillChange.send()
}
+ func refreshServerInformation() async {
+ do {
+ serverInformation = try await xmpp.fetchServerInformation()
+ } catch {
+ // The screen shows its own error state; keep any previously loaded
+ // snapshot rather than clearing it on a transient refresh failure.
+ }
+ }
+
func clearError() {
errorMessage = nil
}
+ func clearInformationalMessage() {
+ informationalMessage = nil
+ }
+
private func beginMediaSendActivity() -> UUID {
let token = mediaSendActivity.begin()
isSendingAttachment = mediaSendActivity.isActive
@@ -1539,7 +2081,8 @@ final class AppModel: ObservableObject {
}
private func updateArchiveSyncSuspensionForMedia() {
- let shouldSuspend = videoNoteCaptureIsActive
+ let shouldSuspend =
+ videoNoteCaptureIsActive
|| mediaSendActivity.isActive
|| !mediaPreparationTokens.isEmpty
guard shouldSuspend != archiveSyncIsSuspendedForMedia else { return }
@@ -1574,7 +2117,8 @@ final class AppModel: ObservableObject {
)
} catch {
guard attempt < maximumAttempts,
- Self.isRetryableMediaSendError(error) else { throw error }
+ Self.isRetryableMediaSendError(error)
+ else { throw error }
if connectionStatus != .connected {
await reconnect()
}
@@ -1588,7 +2132,7 @@ final class AppModel: ObservableObject {
guard let error = error as? LumaXMPPError else { return false }
switch error {
case .notConnected, .connection, .uploadDiscoveryFailed,
- .uploadSlotFailed, .uploadTransportFailed:
+ .uploadSlotFailed, .uploadTransportFailed:
return true
case .uploadFailed(let statusCode):
guard let statusCode else { return true }
@@ -1649,6 +2193,7 @@ final class AppModel: ObservableObject {
// UI from displaying a stale spinner even if the archive
// completion event was overtaken by a socket state change.
isArchiveSyncing = false
+ isLoadingOlderHistory = false
for index in conversations.indices where conversations[index].isGroup {
conversations[index].isGroupJoined = false
conversations[index].occupantCount = 0
@@ -1664,7 +2209,7 @@ final class AppModel: ObservableObject {
// contacts screen as soon as Prosody confirms the roster push.
rosterContactJIDs.remove(jid.lowercased())
case .presence(let jid, let online):
- if let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) {
+ if let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) {
conversations[index].isOnline = online
}
case .message(let envelope):
@@ -1680,13 +2225,15 @@ final class AppModel: ObservableObject {
return
case .groupMessageEcho(let roomJID, let messageID, let stanzaID, let senderJID):
if let index = messageIndex(id: messageID, conversationID: roomJID)
- ?? messageIndex(referenceID: stanzaID, conversationID: roomJID) {
+ ?? messageIndex(referenceID: stanzaID, conversationID: roomJID)
+ {
messages[index].stanzaID = stanzaID
messages[index].senderJID = senderJID
- messageIndexByStanzaKey[Self.localDeletionKey(
- messageID: stanzaID,
- conversationID: roomJID
- )] = index
+ messageIndexByStanzaKey[
+ Self.localDeletionKey(
+ messageID: stanzaID,
+ conversationID: roomJID
+ )] = index
if messages[index].delivery == .sending {
messages[index].delivery = .sent
}
@@ -1699,12 +2246,15 @@ final class AppModel: ObservableObject {
nickname: envelope.nickname,
shouldAutojoin: envelope.joined
)
- if let index = conversations.firstIndex(where: { $0.id == envelope.roomJID.lowercased() }) {
+ if let index = conversations.firstIndex(where: {
+ $0.jid == envelope.roomJID.lowercased()
+ }) {
conversations[index].isGroupJoined = envelope.joined
conversations[index].occupantCount = envelope.occupantCount
}
case .roomInvitation(let invitation):
let jid = invitation.roomJID.lowercased()
+ deletedGroupChatJIDs.remove(jid)
upsertGroupConversation(
jid: jid,
name: nil,
@@ -1726,7 +2276,32 @@ final class AppModel: ObservableObject {
Task { try? await avatarCache.store(data, for: normalized) }
case .delivered(let messageID):
let targetID = correctionReceiptTargets.removeValue(forKey: messageID) ?? messageID
- updateMessage(id: targetID) { $0.delivery = .delivered }
+ // A `` receipt can arrive after the chat was already
+ // opened (or after an XEP-0333 marker marked it read). Merge with
+ // the monotonic ladder instead of overwriting, so `.read` is never
+ // downgraded back to `.delivered`.
+ updateMessage(id: targetID) {
+ $0.delivery = $0.delivery.merged(with: .delivered)
+ }
+ case .read(let conversationID, let messageID):
+ // Peer-originated read receipt (XEP-0333 displayed marker): apply
+ // it locally and broadcast it to the user's other devices through
+ // a self-addressed read-marker message.
+ let targetID = correctionReceiptTargets.removeValue(forKey: messageID) ?? messageID
+ applyReadState(
+ ReadStateSync.Envelope(
+ id: UUID().uuidString,
+ conversationJID: conversationID,
+ messageID: targetID,
+ stanzaID: nil,
+ timestamp: Date()
+ ),
+ cameFromPeer: true
+ )
+ case .readState(let envelope):
+ // Read-marker from another of the user's devices (Carbons live or
+ // MAM replay): apply without re-broadcasting.
+ applyReadState(envelope, cameFromPeer: false)
case .omemo(let ready, let fingerprint):
isOMEMOReady = ready
ownFingerprint = fingerprint
@@ -1738,10 +2313,34 @@ final class AppModel: ObservableObject {
case .archiveSyncCompleted(let checkpoint):
lastSuccessfulMAMSync = checkpoint.timestamp
lastSuccessfulMAMCursor = checkpoint.cursor
+ if let account {
+ mamCheckpoints[.account(account.normalizedJID)] = MAMArchiveCheckpoint(
+ timestamp: checkpoint.timestamp,
+ cursor: checkpoint.cursor
+ )
+ }
+ // Синхронно форсируем persist, чтобы checkpoint не потерялся
+ persistTask?.cancel()
+ persistTask = nil
+ Task { @MainActor [weak self] in
+ guard let self, let store = self.store else { return }
+ try? store.save(
+ locallyDeletedMessageIDs: self.locallyDeletedMessageIDs,
+ rosterContactJIDs: self.rosterContactJIDs,
+ lastSuccessfulMAMSync: self.lastSuccessfulMAMSync,
+ lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor,
+ mamCheckpoints: self.mamCheckpoints
+ )
+ }
+ case .mucArchiveSyncCompleted(let archiveKey, let checkpoint):
+ mamCheckpoints[archiveKey] = checkpoint
+ schedulePersist()
case .call(let call):
activeCall = call
case .callHistory(let entry):
recordCallHistory(entry)
+ case .callHistorySync(let envelope):
+ recordSyncedCallHistory(envelope)
case .callError(let message):
errorMessage = message
case .recoverableError(let message):
@@ -1758,18 +2357,19 @@ final class AppModel: ObservableObject {
private func applyArchiveBatch(_ mutations: [XMPPService.ArchiveMutation]) {
guard !mutations.isEmpty else { return }
- let selectedConversationWasMutated = selectedConversationID.map { selectedID in
- mutations.contains { mutation in
- switch mutation {
- case .message(let envelope):
- return envelope.peerJID.lowercased() == selectedID
- case .retraction(let envelope):
- return envelope.peerJID.lowercased() == selectedID
- case .reaction(let envelope):
- return envelope.peerJID.lowercased() == selectedID
+ let selectedConversationWasMutated =
+ selectedConversationID.map { selectedID in
+ mutations.contains { mutation in
+ switch mutation {
+ case .message(let envelope):
+ return envelope.peerJID.lowercased() == selectedID
+ case .retraction(let envelope):
+ return envelope.peerJID.lowercased() == selectedID
+ case .reaction(let envelope):
+ return envelope.peerJID.lowercased() == selectedID
+ }
}
- }
- } ?? false
+ } ?? false
isApplyingArchiveBatch = true
for mutation in mutations {
switch mutation {
@@ -1797,6 +2397,19 @@ final class AppModel: ObservableObject {
}
private func consumeMessage(_ envelope: XMPPService.MessageEnvelope) {
+ if !envelope.isGroupMessage {
+ upsertConversation(jid: envelope.peerJID, name: nil)
+ }
+ if let stanzaID = envelope.stanzaID,
+ messageIndex(referenceID: stanzaID, conversationID: envelope.peerJID) != nil
+ {
+ return // уже есть это сообщение
+ }
+ if let originID = envelope.originID,
+ messageIndex(originID: originID, conversationID: envelope.peerJID) != nil {
+ mergeServerIdentity(from: envelope, matchingOriginID: originID)
+ return
+ }
if envelope.isGroupMessage {
upsertGroupConversation(
jid: envelope.peerJID,
@@ -1809,14 +2422,17 @@ final class AppModel: ObservableObject {
messageID: envelope.id,
conversationID: envelope.peerJID
)
- let correctionWasDeleted = envelope.correctionTargetID.map {
- locallyDeletedMessageIDs.contains(Self.localDeletionKey(
- messageID: $0,
- conversationID: envelope.peerJID
- ))
- } ?? false
+ let correctionWasDeleted =
+ envelope.correctionTargetID.map {
+ locallyDeletedMessageIDs.contains(
+ Self.localDeletionKey(
+ messageID: $0,
+ conversationID: envelope.peerJID
+ ))
+ } ?? false
guard !locallyDeletedMessageIDs.contains(deletionKey),
- !correctionWasDeleted else { return }
+ !correctionWasDeleted
+ else { return }
if envelope.correctionTargetID != nil {
consumeCorrection(envelope)
return
@@ -1841,6 +2457,7 @@ final class AppModel: ObservableObject {
replyToID: envelope.replyToID,
replyToJID: envelope.replyToJID,
replyPreview: envelope.replyPreview,
+ originID: envelope.originID,
stanzaID: envelope.stanzaID,
senderDisplayName: envelope.senderDisplayName,
isGroupMessage: envelope.isGroupMessage
@@ -1850,7 +2467,7 @@ final class AppModel: ObservableObject {
incrementUnread: envelope.isArchived ? false : nil
)
let pendingKey = Self.localDeletionKey(
- messageID: message.id,
+ messageID: message.clientID,
conversationID: message.conversationID
)
if let correction = pendingCorrections.removeValue(forKey: pendingKey) {
@@ -1861,17 +2478,19 @@ final class AppModel: ObservableObject {
}
applyPendingReactions(for: message)
if !envelope.isArchived,
- NotificationPolicy.shouldPresentMessage(
- inserted: inserted,
- isOutgoing: envelope.isOutgoing,
- appIsActive: appIsActive,
- selectedConversationID: selectedConversationID,
- conversationID: envelope.peerJID
- ) {
- let notificationMessage = self.message(
- withID: envelope.id,
- in: envelope.peerJID
- ) ?? message
+ NotificationPolicy.shouldPresentMessage(
+ inserted: inserted,
+ isOutgoing: envelope.isOutgoing,
+ appIsActive: appIsActive,
+ selectedConversationID: selectedConversationID,
+ conversationID: envelope.peerJID
+ )
+ {
+ let notificationMessage =
+ self.message(
+ withID: envelope.id,
+ in: envelope.peerJID
+ ) ?? message
notifications.showIncomingMessage(
id: envelope.id,
sender: envelope.senderDisplayName ?? conversationName(for: envelope.peerJID),
@@ -1880,10 +2499,178 @@ final class AppModel: ObservableObject {
)
}
}
+
+ private func mergeServerIdentity(
+ from envelope: XMPPService.MessageEnvelope,
+ matchingOriginID originID: String
+ ) {
+ guard let index = messageIndex(
+ originID: originID,
+ conversationID: envelope.peerJID
+ ) else { return }
+ if messages[index].stanzaID == nil {
+ messages[index].stanzaID = envelope.stanzaID
+ }
+ if messages[index].delivery == .sending {
+ messages[index].delivery = .sent
+ }
+ rebuildMessageIndex()
+ schedulePersist()
+ }
+
+ // MARK: - Read receipts (XEP-0333) and cross-device read-state sync
+
+ private func applyReadState(_ envelope: ReadStateSync.Envelope, cameFromPeer: Bool) {
+ let conversationID = envelope.conversationJID.lowercased()
+ if let index =
+ messageIndex(originID: envelope.messageID, conversationID: conversationID)
+ ?? messageIndex(referenceID: envelope.messageID, conversationID: conversationID)
+ ?? envelope.stanzaID.flatMap({
+ messageIndex(referenceID: $0, conversationID: conversationID)
+ })
+ {
+ markRead(at: index, cameFromPeer: cameFromPeer)
+ return
+ }
+ // The marker raced ahead of the message (typical for MAM replays,
+ // which arrive newest first). Apply it when the message lands.
+ stashPendingReadMarker(envelope, cameFromPeer: cameFromPeer)
+ }
+
+ /// Promotes the matching outgoing message and every earlier outgoing
+ /// message in the same conversation to `.read`. XEP-0333 markers are
+ /// cumulative in practice: a marker for message N means the peer has
+ /// displayed the whole conversation through N. Failed messages are never
+ /// promoted by a receipt.
+ private func markRead(at index: Int, cameFromPeer: Bool) {
+ guard messages.indices.contains(index) else { return }
+ let target = messages[index]
+ guard target.direction == .outgoing,
+ target.delivery != .failed,
+ !target.isRetracted
+ else { return }
+
+ let targetConversation = target.conversationID
+ var changed = false
+ for candidateIndex in messages.indices {
+ let candidate = messages[candidateIndex]
+ guard candidate.conversationID == targetConversation,
+ candidate.direction == .outgoing,
+ candidate.delivery != .read,
+ candidate.delivery != .failed,
+ !candidate.isRetracted,
+ candidate.timestamp < target.timestamp
+ || (candidate.timestamp == target.timestamp
+ && candidate.clientID <= target.clientID)
+ else { continue }
+ messages[candidateIndex].delivery = .read
+ changed = true
+ }
+ guard changed else { return }
+ schedulePersist()
+ guard cameFromPeer else { return }
+ xmpp.syncReadState(
+ ReadStateSync.Envelope(
+ id: UUID().uuidString,
+ conversationJID: target.conversationID,
+ messageID: target.originID ?? target.clientID,
+ stanzaID: target.stanzaID,
+ timestamp: Date()
+ ))
+ }
+
+ private func stashPendingReadMarker(_ envelope: ReadStateSync.Envelope, cameFromPeer: Bool) {
+ // The marker may reference the message through its origin-id or its
+ // archived stanza-id; index it under every reference the arriving
+ // message can expose so `applyPendingReadState` always finds it.
+ let references = Set([envelope.messageID, envelope.stanzaID].compactMap { $0 })
+ for reference in references {
+ let key = pendingReadMarkerKey(
+ conversationID: envelope.conversationJID,
+ reference: reference
+ )
+ // A peer marker must keep its broadcast flag: a device-sync
+ // envelope must not silently shadow it.
+ if pendingReadMarkers[key]?.cameFromPeer == true { continue }
+ pendingReadMarkers[key] = PendingReadMarker(envelope: envelope, cameFromPeer: cameFromPeer)
+ }
+ if pendingReadMarkers.count > 200 {
+ if let oldestKey = pendingReadMarkers.min(by: {
+ $0.value.envelope.timestamp < $1.value.envelope.timestamp
+ })?.key {
+ pendingReadMarkers.removeValue(forKey: oldestKey)
+ }
+ }
+ }
+
+ private func pendingReadMarkerKey(conversationID: String, reference: String) -> String {
+ "\(conversationID.lowercased())|\(reference)"
+ }
+
+ /// Applies any read marker that arrived before the message it references.
+ /// Called from `upsertMessage` once the message itself is in the array.
+ private func applyPendingReadState(for message: ChatMessage, at index: Int) {
+ guard message.direction == .outgoing else { return }
+ let references = Set([message.clientID, message.originID, message.stanzaID].compactMap { $0 })
+ for reference in references {
+ let key = pendingReadMarkerKey(
+ conversationID: message.conversationID,
+ reference: reference
+ )
+ guard let pending = pendingReadMarkers.removeValue(forKey: key) else { continue }
+ markRead(at: index, cameFromPeer: pending.cameFromPeer)
+ }
+ }
+
+ /// Self-chat has no peer that could answer with a `` marker,
+ /// so an outgoing message counts as read the moment the server accepted
+ /// it. `markRead` keeps `.failed` messages untouched and never re-syncs
+ /// the state to other devices (cameFromPeer == false).
+ private func markMessageAsReadIfOutgoing(id: String) {
+ guard let account,
+ let index = messageIndex(id: id, conversationID: account.normalizedJID)
+ else { return }
+ markRead(at: index, cameFromPeer: false)
+ }
+
+ /// Sends an XEP-0333 `` marker for the newest incoming 1:1
+ /// message once the chat is opened, so the peer's client can show it as
+ /// read. MUC rooms never get markers (XEP-0333); deduped per message so
+ /// repeated SwiftUI appearances do not resend. Self-chat has no peer, so
+ /// its own outgoing messages are promoted to `.read` locally instead of
+ /// sending a marker to our own bare JID.
+ private func sendDisplayedMarkerIfNeeded(for conversationID: String) {
+ guard let account else { return }
+ let normalized = conversationID.lowercased()
+ if normalized == account.normalizedJID {
+ for index in messages.indices {
+ let message = messages[index]
+ guard message.conversationID == normalized,
+ message.direction == .outgoing,
+ message.delivery != .failed,
+ !message.isRetracted,
+ message.kind != .system
+ else { continue }
+ markRead(at: index, cameFromPeer: false)
+ }
+ return
+ }
+ guard let latest = messages
+ .filter({ $0.conversationID == normalized && $0.direction == .incoming })
+ .max(by: { $0.timestamp < $1.timestamp }),
+ !latest.isGroupMessage,
+ !latest.isRetracted,
+ latest.kind != .system
+ else { return }
+ guard lastDisplayedMarkerByConversation[normalized] != latest.clientID else { return }
+ lastDisplayedMarkerByConversation[normalized] = latest.clientID
+ xmpp.sendDisplayedMarker(messageID: latest.clientID, to: normalized)
+ }
private func recordCallHistory(_ entry: CallHistoryEntry) {
guard let account else { return }
- let direction: ChatMessage.Direction = entry.direction == .outgoing
+ let direction: ChatMessage.Direction =
+ entry.direction == .outgoing
? .outgoing
: .incoming
let metadata = CallHistoryMetadata(
@@ -1903,7 +2690,44 @@ final class AppModel: ObservableObject {
duration: entry.duration,
callHistory: metadata
)
- let isUnreadMissedCall = direction == .incoming
+ let isUnreadMissedCall =
+ direction == .incoming
+ && entry.outcome == .missed
+ && selectedConversationID != entry.peerJID.lowercased()
+ _ = upsertMessage(message, incrementUnread: isUnreadMissedCall)
+ // Sync the card to the user's other devices via Carbons/MAM.
+ xmpp.syncCallHistory(entry)
+ }
+
+ /// Applies a call card received from another of the user's devices. The
+ /// envelope id equals the origin-id of the sync message, which also equals
+ /// the originating device's local clientID, so Carbons and MAM copies
+ /// deduplicate into a single card.
+ private func recordSyncedCallHistory(_ entry: CallHistorySync.Envelope) {
+ guard let account else { return }
+ let direction: ChatMessage.Direction =
+ entry.direction == .outgoing
+ ? .outgoing
+ : .incoming
+ let metadata = CallHistoryMetadata(
+ isVideo: entry.isVideo,
+ outcome: entry.outcome
+ )
+ let message = ChatMessage(
+ id: entry.id,
+ conversationID: entry.peerJID,
+ senderJID: direction == .outgoing ? account.normalizedJID : entry.peerJID,
+ body: entry.isVideo ? "Видеозвонок" : "Аудиозвонок",
+ timestamp: entry.startedAt,
+ direction: direction,
+ delivery: direction == .outgoing ? .sent : .delivered,
+ security: .plaintext,
+ kind: .system,
+ duration: entry.duration,
+ callHistory: metadata
+ )
+ let isUnreadMissedCall =
+ direction == .incoming
&& entry.outcome == .missed
&& selectedConversationID != entry.peerJID.lowercased()
_ = upsertMessage(message, incrementUnread: isUnreadMissedCall)
@@ -1911,14 +2735,16 @@ final class AppModel: ObservableObject {
private func upsertConversation(jid: String, name: String?) {
let normalized = jid.lowercased()
- if let index = conversations.firstIndex(where: { $0.id == normalized }) {
+ if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
conversations[index].displayName = name
}
} else {
- conversations.append(Conversation(jid: normalized, displayName: name))
+ let conversation = Conversation(jid: normalized, displayName: name)
+ modelContext.insert(conversation)
+ conversations.append(conversation)
}
- if conversations.first(where: { $0.id == normalized })?.isGroup != true {
+ if conversations.first(where: { $0.jid == normalized })?.isGroup != true {
if !isApplyingArchiveBatch {
xmpp.prepareDirectChat(with: normalized)
}
@@ -1937,6 +2763,7 @@ final class AppModel: ObservableObject {
invitedBy: String? = nil
) {
let normalized = jid.lowercased()
+ guard !deletedGroupChatJIDs.contains(normalized) else { return }
let fallbackName = normalized.split(separator: "@").first.map(String.init) ?? normalized
let resolvedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)
let initialDisplayName: String
@@ -1945,7 +2772,7 @@ final class AppModel: ObservableObject {
} else {
initialDisplayName = fallbackName
}
- if let index = conversations.firstIndex(where: { $0.id == normalized }) {
+ if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
conversations[index].kind = .group
if let resolvedName, !resolvedName.isEmpty {
conversations[index].displayName = resolvedName
@@ -1955,10 +2782,11 @@ final class AppModel: ObservableObject {
if let nickname, !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
conversations[index].groupNickname = nickname
}
- conversations[index].shouldAutojoin = conversations[index].shouldAutojoin || shouldAutojoin
+ conversations[index].shouldAutojoin =
+ conversations[index].shouldAutojoin || shouldAutojoin
if let invitedBy { conversations[index].invitedBy = invitedBy }
} else {
- conversations.append(Conversation(
+ let conversation = Conversation(
jid: normalized,
displayName: initialDisplayName,
encryptionPreference: .inheritGlobal,
@@ -1966,7 +2794,9 @@ final class AppModel: ObservableObject {
groupNickname: nickname,
shouldAutojoin: shouldAutojoin,
invitedBy: invitedBy
- ))
+ )
+ modelContext.insert(conversation)
+ conversations.append(conversation)
}
if !isApplyingArchiveBatch {
sortConversations()
@@ -1975,7 +2805,8 @@ final class AppModel: ObservableObject {
private var defaultGroupNickname: String {
if let displayName = account?.displayName.trimmingCharacters(in: .whitespacesAndNewlines),
- !displayName.isEmpty {
+ !displayName.isEmpty
+ {
return displayName
}
return account?.normalizedJID.split(separator: "@").first.map(String.init) ?? "Luma"
@@ -1983,17 +2814,21 @@ final class AppModel: ObservableObject {
private func consumeCorrection(_ envelope: XMPPService.MessageEnvelope) {
guard let targetID = envelope.correctionTargetID,
- !locallyDeletedMessageIDs.contains(Self.localDeletionKey(
- messageID: targetID,
- conversationID: envelope.peerJID
- )),
- envelope.kind == .text,
- envelope.security != .decryptionFailed else { return }
+ !locallyDeletedMessageIDs.contains(
+ Self.localDeletionKey(
+ messageID: targetID,
+ conversationID: envelope.peerJID
+ )),
+ envelope.kind == .text,
+ envelope.security != .decryptionFailed
+ else { return }
- guard let index = messageIndex(
- id: targetID,
- conversationID: envelope.peerJID
- ) else {
+ guard
+ let index = messageIndex(
+ id: targetID,
+ conversationID: envelope.peerJID
+ )
+ else {
let pendingKey = Self.localDeletionKey(
messageID: targetID,
conversationID: envelope.peerJID
@@ -2007,18 +2842,19 @@ final class AppModel: ObservableObject {
let original = messages[index]
guard !original.isRetracted,
- original.kind == .text,
- original.conversationID == envelope.peerJID.lowercased(),
- original.senderJID == envelope.senderJID.lowercased(),
- original.security == envelope.security,
- original.editedAt == nil || envelope.timestamp >= original.editedAt! else { return }
+ original.kind == .text,
+ original.conversationID == envelope.peerJID.lowercased(),
+ original.senderJID == envelope.senderJID.lowercased(),
+ original.security == envelope.security,
+ original.editedAt == nil || envelope.timestamp >= original.editedAt!
+ else { return }
messages[index].body = envelope.body
messages[index].editedAt = envelope.timestamp
messages[index].security = envelope.security
messages[index].encryptionFingerprint = envelope.fingerprint
if envelope.isOutgoing {
- messages[index].delivery = .sent
+ messages[index].delivery = messages[index].delivery.merged(with: .sent)
}
updateConversationPreview(for: messages[index], incrementUnread: false)
}
@@ -2036,25 +2872,31 @@ final class AppModel: ObservableObject {
guard allowedSenders.contains(MessageReactionPolicy.bareJID(senderJID)) else { return }
}
- guard let index = messageIndex(
- referenceID: envelope.targetID,
- conversationID: conversationID
- ) else {
+ guard
+ let index = messageIndex(
+ referenceID: envelope.targetID,
+ conversationID: conversationID
+ )
+ else {
let key = pendingReactionKey(envelope)
if let previous = pendingReactions[key],
- previous.timestamp > envelope.timestamp { return }
+ previous.timestamp > envelope.timestamp
+ {
+ return
+ }
pendingReactions[key] = envelope
if pendingReactions.count > 200,
- let staleKey = pendingReactions.min(by: {
- $0.value.timestamp < $1.value.timestamp
- })?.key {
+ let staleKey = pendingReactions.min(by: {
+ $0.value.timestamp < $1.value.timestamp
+ })?.key
+ {
pendingReactions.removeValue(forKey: staleKey)
}
return
}
guard !messages[index].isRetracted else { return }
- var updatedMessage = messages[index]
+ let updatedMessage = messages[index]
let existingIndex = updatedMessage.reactions.firstIndex { reaction in
reactionSenderMatches(
reaction.senderJID,
@@ -2063,7 +2905,8 @@ final class AppModel: ObservableObject {
)
}
if let existingIndex,
- updatedMessage.reactions[existingIndex].updatedAt > envelope.timestamp {
+ updatedMessage.reactions[existingIndex].updatedAt > envelope.timestamp
+ {
return
}
@@ -2079,11 +2922,12 @@ final class AppModel: ObservableObject {
)
}
} else if !emojis.isEmpty {
- updatedMessage.reactions.append(MessageReaction(
- senderJID: senderJID,
- emojis: emojis,
- updatedAt: envelope.timestamp
- ))
+ updatedMessage.reactions.append(
+ MessageReaction(
+ senderJID: senderJID,
+ emojis: emojis,
+ updatedAt: envelope.timestamp
+ ))
} else {
return
}
@@ -2092,7 +2936,7 @@ final class AppModel: ObservableObject {
}
private func applyPendingReactions(for message: ChatMessage) {
- let identifiers = Set([message.id, message.stanzaID].compactMap { $0 })
+ let identifiers = Set([message.clientID, message.stanzaID].compactMap { $0 })
guard !identifiers.isEmpty else { return }
let matches = pendingReactions.filter { _, envelope in
envelope.peerJID.lowercased() == message.conversationID
@@ -2108,7 +2952,7 @@ final class AppModel: ObservableObject {
[
envelope.peerJID.lowercased(),
envelope.targetID,
- envelope.senderJID.lowercased()
+ envelope.senderJID.lowercased(),
].joined(separator: "|")
}
@@ -2123,7 +2967,8 @@ final class AppModel: ObservableObject {
private func applyRemoteChatState(_ envelope: XMPPService.ChatStateEnvelope) {
let conversationID = envelope.peerJID.lowercased()
- let senderKey = envelope.isGroupMessage
+ let senderKey =
+ envelope.isGroupMessage
? envelope.senderJID.lowercased()
: MessageReactionPolicy.bareJID(envelope.senderJID)
let expiryKey = "\(conversationID)|\(senderKey)"
@@ -2139,7 +2984,8 @@ final class AppModel: ObservableObject {
}
var participants = typingParticipantsByConversation[conversationID] ?? [:]
- participants[senderKey] = envelope.senderDisplayName
+ participants[senderKey] =
+ envelope.senderDisplayName
?? displayName(for: envelope.senderJID)
typingParticipantsByConversation[conversationID] = participants
remoteTypingExpiryTasks[expiryKey] = Task { @MainActor [weak self] in
@@ -2174,7 +3020,7 @@ final class AppModel: ObservableObject {
in conversation: Conversation
) {
guard typingIndicatorsEnabled, connectionStatus == .connected else { return }
- let conversationID = conversation.id.lowercased()
+ let conversationID = conversation.jid.lowercased()
let previous = localChatStateByConversation[conversationID]
if state == .paused, previous != .composing { return }
if state == .inactive, previous == nil { return }
@@ -2200,14 +3046,19 @@ final class AppModel: ObservableObject {
}
private func applyRetraction(_ envelope: XMPPService.RetractionEnvelope) {
- guard !locallyDeletedMessageIDs.contains(Self.localDeletionKey(
- messageID: envelope.targetID,
- conversationID: envelope.peerJID
- )) else { return }
- guard let index = messageIndex(
- id: envelope.targetID,
- conversationID: envelope.peerJID
- ) else {
+ guard
+ !locallyDeletedMessageIDs.contains(
+ Self.localDeletionKey(
+ messageID: envelope.targetID,
+ conversationID: envelope.peerJID
+ ))
+ else { return }
+ guard
+ let index = messageIndex(
+ id: envelope.targetID,
+ conversationID: envelope.peerJID
+ )
+ else {
let pendingKey = Self.localDeletionKey(
messageID: envelope.targetID,
conversationID: envelope.peerJID
@@ -2221,10 +3072,11 @@ final class AppModel: ObservableObject {
let original = messages[index]
guard original.conversationID == envelope.peerJID.lowercased(),
- original.senderJID == envelope.senderJID.lowercased(),
- original.retractedAt == nil || envelope.timestamp >= original.retractedAt! else { return }
+ original.senderJID == envelope.senderJID.lowercased(),
+ original.retractedAt == nil || envelope.timestamp >= original.retractedAt!
+ else { return }
- removeCachedMedia(for: original.id)
+ removeCachedMedia(for: original.clientID)
messages[index].body = "Сообщение удалено"
messages[index].kind = .system
messages[index].remoteAttachmentURL = nil
@@ -2243,10 +3095,11 @@ final class AppModel: ObservableObject {
if envelope.isOutgoing, messages[index].delivery == .sending {
messages[index].delivery = .sent
}
- pendingCorrections.removeValue(forKey: Self.localDeletionKey(
- messageID: original.id,
- conversationID: original.conversationID
- ))
+ pendingCorrections.removeValue(
+ forKey: Self.localDeletionKey(
+ messageID: original.clientID,
+ conversationID: original.conversationID
+ ))
updateConversationPreview(for: messages[index], incrementUnread: false)
}
@@ -2255,32 +3108,33 @@ final class AppModel: ObservableObject {
_ message: ChatMessage,
incrementUnread unreadOverride: Bool? = nil
) -> Bool {
- guard !locallyDeletedMessageIDs.contains(Self.localDeletionKey(
- messageID: message.id,
- conversationID: message.conversationID
- )) else { return false }
+ guard
+ !locallyDeletedMessageIDs.contains(
+ Self.localDeletionKey(
+ messageID: message.clientID,
+ conversationID: message.conversationID
+ ))
+ else { return false }
upsertConversation(jid: message.conversationID, name: nil)
if let index = messageIndex(
- id: message.id,
+ id: message.clientID,
conversationID: message.conversationID
) {
let previous = messages[index]
if previous.isRetracted {
- if message.delivery == .delivered {
- messages[index].delivery = .delivered
- }
+ messages[index].delivery = previous.delivery.merged(with: message.delivery)
updateConversationPreview(for: messages[index], incrementUnread: false)
return false
}
- var merged = message
+ let merged = message
if previous.direction == .outgoing && message.direction == .outgoing {
merged.localFilename = previous.localFilename ?? message.localFilename
merged.mimeType = previous.mimeType ?? message.mimeType
merged.duration = previous.duration ?? message.duration
merged.byteCount = previous.byteCount ?? message.byteCount
- if previous.delivery == .delivered {
- merged.delivery = .delivered
- }
+ // MAM/MUC-MAM replays and Carbon copies carry `.sent`; a
+ // locally delivered or read copy must never be downgraded.
+ merged.delivery = previous.delivery.merged(with: message.delivery)
}
merged.replyToID = message.replyToID ?? previous.replyToID
merged.replyToJID = message.replyToJID ?? previous.replyToJID
@@ -2294,53 +3148,103 @@ final class AppModel: ObservableObject {
merged.reactions = previous.reactions
}
if let editedAt = previous.editedAt,
- message.editedAt == nil || editedAt > message.editedAt! {
+ message.editedAt == nil || editedAt > message.editedAt!
+ {
merged.body = previous.body
merged.editedAt = editedAt
merged.security = previous.security
merged.encryptionFingerprint = previous.encryptionFingerprint
}
- messages[index] = merged
- if let stanzaID = merged.stanzaID {
- messageIndexByStanzaKey[Self.localDeletionKey(
- messageID: stanzaID,
- conversationID: merged.conversationID
- )] = index
+ if message.security == .decryptionFailed,
+ previous.security != .decryptionFailed,
+ !previous.body.isEmpty
+ {
+ // Our own outgoing message was rendered optimistically with the
+ // text the user typed. Its server echo (carbon or MAM) cannot
+ // be decrypted back (no encrypt-to-self key, or a consumed
+ // session), so keep the original content instead of replacing
+ // it with a "failed to decrypt" placeholder.
+ merged.body = previous.body
+ merged.security = previous.security
+ merged.encryptionFingerprint = previous.encryptionFingerprint
+ merged.kind = previous.kind
+ merged.remoteAttachmentURL = previous.remoteAttachmentURL
+ merged.localFilename = previous.localFilename
+ merged.mimeType = previous.mimeType
+ merged.duration = previous.duration
+ merged.byteCount = previous.byteCount
}
+ messages[index] = merged
+ if previous !== merged {
+ // `merged` is the freshly-created incoming message; replace the
+ // previously managed object with it so SwiftData does not keep
+ // an orphaned duplicate of the same clientID.
+ modelContext.delete(previous)
+ modelContext.insert(merged)
+ }
+ if let stanzaID = merged.stanzaID {
+ messageIndexByStanzaKey[
+ Self.localDeletionKey(
+ messageID: stanzaID,
+ conversationID: merged.conversationID
+ )] = index
+ }
+ if let originID = merged.originID {
+ messageIndexByOriginKey[
+ Self.localDeletionKey(
+ messageID: originID,
+ conversationID: merged.conversationID
+ )] = index
+ }
+ applyPendingReadState(for: merged, at: index)
updateConversationPreview(for: merged, incrementUnread: false)
return false
}
+ modelContext.insert(message)
messages.append(message)
let insertedIndex = messages.index(before: messages.endIndex)
- messageIndexByStorageKey[Self.localDeletionKey(
- messageID: message.id,
- conversationID: message.conversationID
- )] = insertedIndex
- if firstMessageIndexByID[message.id] == nil {
- firstMessageIndexByID[message.id] = insertedIndex
- }
- if let stanzaID = message.stanzaID {
- messageIndexByStanzaKey[Self.localDeletionKey(
- messageID: stanzaID,
+ messageIndexByStorageKey[
+ Self.localDeletionKey(
+ messageID: message.clientID,
conversationID: message.conversationID
)] = insertedIndex
+ if firstMessageIndexByID[message.clientID] == nil {
+ firstMessageIndexByID[message.clientID] = insertedIndex
}
- let shouldIncrement = unreadOverride
+ if let stanzaID = message.stanzaID {
+ messageIndexByStanzaKey[
+ Self.localDeletionKey(
+ messageID: stanzaID,
+ conversationID: message.conversationID
+ )] = insertedIndex
+ }
+ if let originID = message.originID {
+ messageIndexByOriginKey[
+ Self.localDeletionKey(
+ messageID: originID,
+ conversationID: message.conversationID
+ )] = insertedIndex
+ }
+ let shouldIncrement =
+ unreadOverride
?? (message.direction == .incoming && selectedConversationID != message.conversationID)
+ applyPendingReadState(for: message, at: insertedIndex)
updateConversationPreview(for: message, incrementUnread: shouldIncrement)
return true
}
private func updateMessage(id: String, mutation: (inout ChatMessage) -> Void) {
guard let index = firstMessageIndexByID[id],
- messages.indices.contains(index),
- messages[index].id == id else { return }
+ messages.indices.contains(index),
+ messages[index].clientID == id
+ else { return }
mutation(&messages[index])
updateConversationPreview(for: messages[index], incrementUnread: false)
}
private func updateConversationPreview(for message: ChatMessage, incrementUnread: Bool) {
- guard let index = conversations.firstIndex(where: { $0.id == message.conversationID }) else { return }
+ guard let index = conversations.firstIndex(where: { $0.jid == message.conversationID })
+ else { return }
if message.timestamp >= conversations[index].lastActivity {
conversations[index].lastActivity = message.timestamp
conversations[index].lastMessage = message.previewText
@@ -2354,11 +3258,14 @@ final class AppModel: ObservableObject {
}
private func rebuildConversationPreview(for conversationID: String) {
- guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return }
- let latest = messages
+ guard let index = conversations.firstIndex(where: { $0.jid == conversationID }) else {
+ return
+ }
+ let latest =
+ messages
.filter { $0.conversationID == conversationID }
.max { lhs, rhs in
- if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
+ if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
return lhs.timestamp < rhs.timestamp
}
conversations[index].lastActivity = latest?.timestamp ?? .distantPast
@@ -2367,14 +3274,15 @@ final class AppModel: ObservableObject {
}
private func conversationName(for jid: String) -> String {
- conversations.first(where: { $0.id == jid.lowercased() })?.displayName ?? jid
+ conversations.first(where: { $0.jid == jid.lowercased() })?.displayName ?? jid
}
private func sortConversations() {
conversations.sort { lhs, rhs in
if lhs.isPinned != rhs.isPinned { return lhs.isPinned }
if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity }
- return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
+ return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
+ == .orderedAscending
}
}
@@ -2386,8 +3294,9 @@ final class AppModel: ObservableObject {
)
guard let index = messageIndexByStorageKey[key] else { return nil }
if messages.indices.contains(index),
- messages[index].id == id,
- messages[index].conversationID == normalizedConversationID {
+ messages[index].clientID == id,
+ messages[index].conversationID == normalizedConversationID
+ {
return index
}
rebuildMessageIndex()
@@ -2403,30 +3312,52 @@ final class AppModel: ObservableObject {
messageID: referenceID,
conversationID: normalizedConversationID
)
- if let index = messageIndexByStanzaKey[key],
- messages.indices.contains(index),
- messages[index].conversationID == normalizedConversationID,
- messages[index].stanzaID == referenceID {
+ guard let index = messageIndexByStanzaKey[key] else { return nil }
+ if messages.indices.contains(index),
+ messages[index].conversationID == normalizedConversationID,
+ messages[index].stanzaID == referenceID
+ {
+ return index
+ }
+ // Only rebuild when the key exists but points at a stale entry. A
+ // genuine miss is expected during archive catch-up for every new
+ // stanza-id and must not trigger an O(n) rebuild per message.
+ rebuildMessageIndex()
+ return messageIndexByStanzaKey[key]
+ }
+
+ private func messageIndex(originID: String, conversationID: String) -> Int? {
+ let normalizedConversationID = conversationID.lowercased()
+ let key = Self.localDeletionKey(
+ messageID: originID,
+ conversationID: normalizedConversationID
+ )
+ guard let index = messageIndexByOriginKey[key] else { return nil }
+ if messages.indices.contains(index),
+ messages[index].conversationID == normalizedConversationID,
+ messages[index].originID == originID
+ {
return index
}
rebuildMessageIndex()
- return messageIndexByStanzaKey[key]
+ return messageIndexByOriginKey[key]
}
private func rebuildMessageIndex() {
messageIndexByStorageKey.removeAll(keepingCapacity: true)
messageIndexByStanzaKey.removeAll(keepingCapacity: true)
+ messageIndexByOriginKey.removeAll(keepingCapacity: true)
firstMessageIndexByID.removeAll(keepingCapacity: true)
for (index, message) in messages.enumerated() {
let key = Self.localDeletionKey(
- messageID: message.id,
+ messageID: message.clientID,
conversationID: message.conversationID
)
if messageIndexByStorageKey[key] == nil {
messageIndexByStorageKey[key] = index
}
- if firstMessageIndexByID[message.id] == nil {
- firstMessageIndexByID[message.id] = index
+ if firstMessageIndexByID[message.clientID] == nil {
+ firstMessageIndexByID[message.clientID] = index
}
if let stanzaID = message.stanzaID {
let stanzaKey = Self.localDeletionKey(
@@ -2437,28 +3368,32 @@ final class AppModel: ObservableObject {
messageIndexByStanzaKey[stanzaKey] = index
}
}
+ if let originID = message.originID {
+ let originKey = Self.localDeletionKey(
+ messageID: originID,
+ conversationID: message.conversationID
+ )
+ if messageIndexByOriginKey[originKey] == nil {
+ messageIndexByOriginKey[originKey] = index
+ }
+ }
}
}
private func loadArchive(for jid: String) async {
resetArchiveBatchState()
resetMediaPreviews()
- let archive = ChatArchive(accountJID: jid)
- self.archive = archive
- let snapshot = await archive.load()
- let migrateLegacyGroupEncryption = snapshot.schemaVersion < 2
- conversations = snapshot.conversations.map { stored in
- var conversation = stored
+ prepareStore(for: jid)
+ guard let store else {
+ fallbackContext = nil
+ return
+ }
+ self.store = store
+ let snapshot = store.load()
+ conversations = snapshot.conversations.map { conversation in
if conversation.isGroup {
conversation.isGroupJoined = false
conversation.occupantCount = 0
- if migrateLegacyGroupEncryption, conversation.encryptionPreference == .disabled {
- // Older releases forced every MUC to disabled and had no
- // group encryption control, so this value was not a user
- // choice. Migrate it once, while preserving later explicit
- // per-room opt-outs in schema v2 snapshots.
- conversation.encryptionPreference = .inheritGlobal
- }
}
return conversation
}
@@ -2466,13 +3401,39 @@ final class AppModel: ObservableObject {
locallyDeletedMessageIDs = snapshot.locallyDeletedMessageIDs
lastSuccessfulMAMSync = snapshot.lastSuccessfulMAMSync
lastSuccessfulMAMCursor = snapshot.lastSuccessfulMAMCursor
+ mamCheckpoints = snapshot.mamCheckpoints
+ if mamCheckpoints.isEmpty, let lastSuccessfulMAMSync {
+ mamCheckpoints[.account(jid)] = MAMArchiveCheckpoint(
+ timestamp: lastSuccessfulMAMSync,
+ cursor: lastSuccessfulMAMCursor
+ )
+ }
+ // One-time cleanup of phantom «Не удалось расшифровать сообщение»
+ // placeholders: outgoing echoes the client could not decrypt back
+ // (broken encrypt-to-self sessions) used to be rendered as
+ // undecryptable bubbles. XMPPService now drops those echoes, so purge
+ // the leftovers from the local store instead of keeping the noise.
+ let phantomPlaceholderIDs = Set(
+ snapshot.messages
+ .filter { $0.direction == .outgoing && $0.security == .decryptionFailed }
+ .map(\.clientID)
+ )
+ for message in snapshot.messages
+ where phantomPlaceholderIDs.contains(message.clientID) {
+ modelContext.delete(message)
+ }
messages = snapshot.messages.filter {
- !locallyDeletedMessageIDs.contains(Self.localDeletionKey(
- messageID: $0.id,
- conversationID: $0.conversationID
- ))
+ !phantomPlaceholderIDs.contains($0.clientID)
+ && !locallyDeletedMessageIDs.contains(
+ Self.localDeletionKey(
+ messageID: $0.clientID,
+ conversationID: $0.conversationID
+ ))
}
rebuildMessageIndex()
+ if !phantomPlaceholderIDs.isEmpty {
+ schedulePersist()
+ }
let avatarJIDs = Set(snapshot.conversations.filter { !$0.isGroup }.map(\.jid) + [jid])
for avatarJID in avatarJIDs {
if let data = await avatarCache.data(for: avatarJID) {
@@ -2484,20 +3445,18 @@ final class AppModel: ObservableObject {
}
private func schedulePersist() {
- guard let archive else { return }
+ guard let store else { return }
persistTask?.cancel()
persistTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled, let self else { return }
- let snapshot = ChatArchive.Snapshot(
- conversations: self.conversations,
- messages: self.messages,
+ try? store.save(
locallyDeletedMessageIDs: self.locallyDeletedMessageIDs,
rosterContactJIDs: self.rosterContactJIDs,
lastSuccessfulMAMSync: self.lastSuccessfulMAMSync,
- lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor
+ lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor,
+ mamCheckpoints: self.mamCheckpoints
)
- try? await archive.save(snapshot)
}
}
@@ -2522,7 +3481,8 @@ final class AppModel: ObservableObject {
private func loadCachedAvatarIfNeeded(for jid: String) {
let normalized = jid.lowercased()
guard avatarDataByJID[normalized] == nil,
- avatarCacheRequests.insert(normalized).inserted else { return }
+ avatarCacheRequests.insert(normalized).inserted
+ else { return }
Task { [weak self] in
guard let self, let data = await self.avatarCache.data(for: normalized) else { return }
self.avatarDataByJID[normalized] = data
@@ -2536,7 +3496,7 @@ final class AppModel: ObservableObject {
"LumaForwarding",
"LumaAttachmentDrafts",
"LumaPhotoPicker",
- "LumaDuration"
+ "LumaDuration",
] {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(directoryName, isDirectory: true)
@@ -2559,10 +3519,11 @@ final class AppModel: ObservableObject {
for directoryName in ["LumaMediaPreviews", "LumaPreview", "LumaForwarding"] {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(directoryName, isDirectory: true)
- let urls = (try? FileManager.default.contentsOfDirectory(
- at: directory,
- includingPropertiesForKeys: nil
- )) ?? []
+ let urls =
+ (try? FileManager.default.contentsOfDirectory(
+ at: directory,
+ includingPropertiesForKeys: nil
+ )) ?? []
for url in urls where url.lastPathComponent.hasPrefix(prefix) {
if previewURL == url { previewURL = nil }
try? FileManager.default.removeItem(at: url)
@@ -2596,7 +3557,8 @@ final class AppModel: ObservableObject {
if let preparedThumbnailData {
mediaThumbnailData[messageID] = preparedThumbnailData
}
- let hasCompletePreparedPreview = preparedThumbnailData != nil
+ let hasCompletePreparedPreview =
+ preparedThumbnailData != nil
&& (kind == .photo || kind == .video)
guard !hasCompletePreparedPreview else { return }
Task { @MainActor [weak self] in
@@ -2630,10 +3592,11 @@ final class AppModel: ObservableObject {
audioWaveformSamples[messageID] = waveform
}
if let duration = analysis.duration,
- let index = firstMessageIndexByID[messageID],
- messages.indices.contains(index),
- messages[index].id == messageID,
- messages[index].duration == nil {
+ let index = firstMessageIndexByID[messageID],
+ messages.indices.contains(index),
+ messages[index].clientID == messageID,
+ messages[index].duration == nil
+ {
messages[index].duration = duration
schedulePersist()
}
@@ -2647,7 +3610,7 @@ final class AppModel: ObservableObject {
throw AttachmentError.missingRemoteURL
}
let output = try Self.previewFileURL(
- messageID: message.id,
+ messageID: message.clientID,
filename: message.localFilename ?? "attachment",
directoryName: directoryName
)
@@ -2678,7 +3641,8 @@ final class AppModel: ObservableObject {
private static func isValidJID(_ jid: String) -> Bool {
let pieces = jid.split(separator: "@", omittingEmptySubsequences: false)
- return pieces.count == 2 && !pieces[0].isEmpty && !pieces[1].isEmpty && !jid.contains(where: { $0.isWhitespace })
+ return pieces.count == 2 && !pieces[0].isEmpty && !pieces[1].isEmpty
+ && !jid.contains(where: { $0.isWhitespace })
}
private static func mediaKind(for contentType: UTType?, filename: String) -> ChatMessage.Kind {
@@ -2739,7 +3703,7 @@ final class AppModel: ObservableObject {
}
private static func localDeletionKey(messageID: String, conversationID: String) -> String {
- conversationID.lowercased() + "\u{1F}" + messageID
+ ArchiveStore.deletionKey(messageID: messageID, conversationID: conversationID)
}
}
diff --git a/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift b/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift
index fbef6d5..2493b68 100644
--- a/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift
+++ b/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift
@@ -4,12 +4,16 @@ import Foundation
/// still published atomically; these values only bound network and decryption
/// work performed before that publication.
enum ArchiveMessageBatchPolicy {
- static let pageSize = 24
+ static let pageSize = 40
static let bootstrapMessageLimit = 40
- static let decodeSliceSize = 1
- static let maximumBufferedStanzas = 48
- /// Leave roughly half of a 60 Hz frame between archived decryptions. This
- /// keeps UIScrollView touch handling responsive even when OMEMO work is
- /// unusually expensive on older devices.
- static let interSliceDelayNanoseconds: UInt64 = 8_000_000
+ /// Decrypt this many archived stanzas per main-actor slice before yielding.
+ /// One-by-one decryption turns a large MAM catch-up into hours of serialized
+ /// work; a small batch keeps frames mostly intact while cutting the number
+ /// of run-loop hand-offs by the same factor.
+ static let decodeSliceSize = 8
+ static let maximumBufferedStanzas = 96
+ /// A short yield between archived decryption slices keeps touch handling
+ /// responsive without the 8 ms per single message that previously dominated
+ /// catch-up time.
+ static let interSliceDelayNanoseconds: UInt64 = 1_000_000
}
diff --git a/Sources/Shared/Models/ArchiveSyncCheckpoint.swift b/Sources/Shared/Models/ArchiveSyncCheckpoint.swift
index 9401ca4..4eb60c0 100644
--- a/Sources/Shared/Models/ArchiveSyncCheckpoint.swift
+++ b/Sources/Shared/Models/ArchiveSyncCheckpoint.swift
@@ -1,5 +1,40 @@
import Foundation
+/// A MAM UID is only meaningful inside one archive. Account MAM and every MUC
+/// therefore get their own namespace and durable checkpoint.
+struct MAMArchiveKey: Codable, Hashable, Sendable {
+ enum Kind: String, Codable, Sendable {
+ case account
+ case muc
+ }
+
+ let kind: Kind
+ let jid: String
+
+ init(kind: Kind, jid: String) {
+ self.kind = kind
+ self.jid = jid.lowercased()
+ }
+
+ static func account(_ jid: String) -> Self {
+ Self(kind: .account, jid: jid)
+ }
+
+ static func muc(_ jid: String) -> Self {
+ Self(kind: .muc, jid: jid)
+ }
+}
+
+struct MAMArchiveCheckpoint: Codable, Equatable, Sendable {
+ let timestamp: Date
+ let cursor: String?
+
+ init(timestamp: Date, cursor: String? = nil) {
+ self.timestamp = timestamp
+ self.cursor = ArchiveSyncCheckpoint.normalizedCursor(cursor)
+ }
+}
+
/// Durable MAM position. The archive UID is the primary synchronization
/// anchor; the timestamp is retained as a compatibility fallback for legacy
/// snapshots and servers that have expired an old UID.
diff --git a/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift b/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift
index 0098e4a..5452327 100644
--- a/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift
+++ b/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift
@@ -5,8 +5,15 @@ import Foundation
enum ArchiveSyncRecoveryPolicy {
static let incrementalOverlap: TimeInterval = 60
static let interPageDelayNanoseconds: UInt64 = 100_000_000
- static let queryTimeoutNanoseconds: UInt64 = 12_000_000_000
- static let pageApplyTimeoutNanoseconds: UInt64 = 8_000_000_000
+ static let queryTimeoutNanoseconds: UInt64 = 20_000_000_000
+ static let pageApplyTimeoutNanoseconds: UInt64 = 20_000_000_000
static let pageRetryLimit = 1
static let resumeAfterCaptureDelayNanoseconds: UInt64 = 3_000_000_000
+ /// Delay before automatically resuming a failed catch-up pass while the
+ /// app stays connected and foregrounded. Prevents a single slow page from
+ /// silently leaving history unloaded until the next app activation.
+ static let retryAfterFailureDelayNanoseconds: UInt64 = 20_000_000_000
+ /// Consecutive automatic catch-up retries before falling back to
+ /// resume-on-activation, so a persistently broken server cannot loop.
+ static let maximumAutomaticRetries = 3
}
diff --git a/Sources/Shared/Models/ChatMessage.swift b/Sources/Shared/Models/ChatMessage.swift
index 073af59..9ed4af1 100644
--- a/Sources/Shared/Models/ChatMessage.swift
+++ b/Sources/Shared/Models/ChatMessage.swift
@@ -1,25 +1,49 @@
import Foundation
+import SwiftData
-struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
- enum Direction: String, Codable, Sendable {
+@Model
+final class ChatMessage {
+ enum Direction: String, Codable {
case incoming
case outgoing
}
- enum Delivery: String, Codable, Sendable {
+ enum Delivery: String, Codable {
case sending
case sent
case delivered
case failed
+ case read
+
+ /// Monotonic promotion used when the same outgoing message is replayed
+ /// from MAM/MUC-MAM or Carbons: a copy that was already delivered or
+ /// read locally must never be downgraded by an archive replay carrying
+ /// `.sent`. `.failed` is sticky in both directions — an archive
+ /// replay must not silently "fix" a failed send; the user resends it
+ /// explicitly.
+ func merged(with other: Delivery) -> Delivery {
+ guard self != .failed, other != .failed else { return .failed }
+ return other.rank > self.rank ? other : self
+ }
+
+ private var rank: Int {
+ switch self {
+ case .sending: return 0
+ case .sent: return 1
+ case .delivered: return 2
+ case .read: return 3
+ case .failed: return -1
+ }
+ }
}
- enum Security: String, Codable, Sendable {
+ enum Security: String, Codable {
case omemo
case plaintext
case decryptionFailed
}
- enum Kind: String, Codable, Sendable {
+ enum Kind: String, Codable {
case text
case attachment
case photo
@@ -40,7 +64,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
}
}
- let id: String
+ @Attribute(.unique) var clientID: String
var conversationID: String
var senderJID: String
var body: String
@@ -61,12 +85,15 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
var replyPreview: String?
var forwardedFrom: String?
var retractedAt: Date?
+ var originID: String?
var stanzaID: String?
var senderDisplayName: String?
var isGroupMessage: Bool
var callHistory: CallHistoryMetadata?
var reactions: [MessageReaction]
+ var conversation: Conversation?
+
init(
id: String = UUID().uuidString,
conversationID: String,
@@ -89,13 +116,14 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
replyPreview: String? = nil,
forwardedFrom: String? = nil,
retractedAt: Date? = nil,
+ originID: String? = nil,
stanzaID: String? = nil,
senderDisplayName: String? = nil,
isGroupMessage: Bool = false,
callHistory: CallHistoryMetadata? = nil,
reactions: [MessageReaction] = []
) {
- self.id = id
+ self.clientID = id
self.conversationID = conversationID.lowercased()
self.senderJID = Self.normalizedSenderJID(senderJID)
self.body = body
@@ -116,6 +144,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
self.replyPreview = replyPreview
self.forwardedFrom = forwardedFrom
self.retractedAt = retractedAt
+ self.originID = originID ?? (direction == .outgoing ? id : nil)
self.stanzaID = stanzaID
self.senderDisplayName = senderDisplayName
self.isGroupMessage = isGroupMessage
@@ -123,71 +152,12 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
self.reactions = reactions
}
- private enum CodingKeys: String, CodingKey {
- case id
- case conversationID
- case senderJID
- case body
- case timestamp
- case direction
- case delivery
- case security
- case kind
- case remoteAttachmentURL
- case localFilename
- case mimeType
- case duration
- case byteCount
- case encryptionFingerprint
- case editedAt
- case replyToID
- case replyToJID
- case replyPreview
- case forwardedFrom
- case retractedAt
- case stanzaID
- case senderDisplayName
- case isGroupMessage
- case callHistory
- case reactions
- }
-
- init(from decoder: Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- id = try values.decode(String.self, forKey: .id)
- conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
- senderJID = Self.normalizedSenderJID(try values.decode(String.self, forKey: .senderJID))
- body = try values.decode(String.self, forKey: .body)
- timestamp = try values.decode(Date.self, forKey: .timestamp)
- direction = try values.decode(Direction.self, forKey: .direction)
- delivery = try values.decode(Delivery.self, forKey: .delivery)
- security = try values.decode(Security.self, forKey: .security)
- kind = try values.decode(Kind.self, forKey: .kind)
- remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
- localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
- mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
- duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
- byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
- encryptionFingerprint = try values.decodeIfPresent(String.self, forKey: .encryptionFingerprint)
- editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
- replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
- replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID).map(Self.normalizedSenderJID)
- replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
- forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
- retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
- stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
- senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
- isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
- callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
- reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
- }
-
var canBeEdited: Bool {
!isRetracted
&& !isGroupMessage
&& direction == .outgoing
&& kind == .text
- && (delivery == .sent || delivery == .delivered)
+ && (delivery == .sent || delivery == .delivered || delivery == .read)
}
var isRetracted: Bool {
@@ -209,17 +179,18 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
&& !isGroupMessage
&& !isRetracted
&& kind != .system
- && (delivery == .sent || delivery == .delivered)
+ && (delivery == .sent || delivery == .delivered || delivery == .read)
}
var replyIdentifier: String? {
- isGroupMessage ? stanzaID : id
+ isGroupMessage ? stanzaID : clientID
}
var quotePreview: String {
if isRetracted { return "Сообщение удалено" }
let value = kind == .text ? body : previewText
- let normalized = value
+ let normalized =
+ value
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalized.count > 140 else { return normalized }
@@ -316,4 +287,15 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
let bare = value[.. [String: TimeInterval] {
+ let last = ids.count - 1
+ guard last >= 0 else { return [:] }
+ let firstAnimated = max(0, ids.count - entranceCascadeLimit)
+ var delays: [String: TimeInterval] = [:]
+ delays.reserveCapacity(ids.count - firstAnimated)
+ for index in firstAnimated...last {
+ delays[ids[index]] = TimeInterval(last - index) * entranceStagger
+ }
+ return delays
+ }
+
+ /// Rows a query refresh added below everything already on screen.
+ ///
+ /// A MAM page paged in above the viewport is deliberately ignored: replaying
+ /// the entrance mid-scroll would yank the timeline the user is reading. A
+ /// burst of rows is treated as the same kind of archive delivery.
+ static func appendedLaunchIDs(
+ in ids: [String],
+ after previous: Set
+ ) -> [String] {
+ guard !previous.isEmpty else { return [] }
+ guard let lastKnownIndex = ids.lastIndex(where: previous.contains)
+ else { return [] }
+ let appended = ids.dropFirst(lastKnownIndex + 1).filter {
+ !previous.contains($0)
+ }
+ guard appended.count <= appendAnimationLimit else { return [] }
+ return Array(appended)
+ }
+}
\ No newline at end of file
diff --git a/Sources/Shared/Models/ChatTimelineEntry.swift b/Sources/Shared/Models/ChatTimelineEntry.swift
index aa15796..7188d20 100644
--- a/Sources/Shared/Models/ChatTimelineEntry.swift
+++ b/Sources/Shared/Models/ChatTimelineEntry.swift
@@ -3,11 +3,21 @@ import Foundation
/// A cached presentation row for the selected chat. Building day boundaries
/// once per message mutation avoids walking the full history during every
/// scroll-position or composer-state update.
-struct ChatTimelineEntry: Identifiable, Hashable, Sendable {
+struct ChatTimelineEntry: Identifiable, Hashable {
let message: ChatMessage
let startsNewDay: Bool
- var id: String { message.id }
+ var id: String { message.clientID }
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.message.clientID == rhs.message.clientID
+ && lhs.startsNewDay == rhs.startsNewDay
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(message.clientID)
+ hasher.combine(startsNewDay)
+ }
static func make(
from messages: [ChatMessage],
diff --git a/Sources/Shared/Models/Conversation.swift b/Sources/Shared/Models/Conversation.swift
index dab4911..a662e77 100644
--- a/Sources/Shared/Models/Conversation.swift
+++ b/Sources/Shared/Models/Conversation.swift
@@ -1,13 +1,14 @@
import Foundation
+import SwiftData
-struct Conversation: Codable, Identifiable, Hashable, Sendable {
- enum Kind: String, Codable, Sendable {
+@Model
+final class Conversation {
+ enum Kind: String, Codable {
case direct
case group
}
- let id: String
- var jid: String
+ @Attribute(.unique) var jid: String
var displayName: String
var lastMessage: String
var lastActivity: Date
@@ -22,6 +23,9 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
var occupantCount: Int
var invitedBy: String?
+ @Relationship(deleteRule: .cascade, inverse: \ChatMessage.conversation)
+ var messages: [ChatMessage] = []
+
init(
jid: String,
displayName: String? = nil,
@@ -39,7 +43,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
invitedBy: String? = nil
) {
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- self.id = normalized
self.jid = normalized
self.displayName = displayName?.nilIfBlank ?? normalized
self.lastMessage = lastMessage
@@ -56,46 +59,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
self.invitedBy = invitedBy?.nilIfBlank
}
- private enum CodingKeys: String, CodingKey {
- case id
- case jid
- case displayName
- case lastMessage
- case lastActivity
- case unreadCount
- case isOnline
- case isPinned
- case encryptionPreference
- case kind
- case groupNickname
- case isGroupJoined
- case shouldAutojoin
- case occupantCount
- case invitedBy
- }
-
- init(from decoder: Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- jid = try values.decode(String.self, forKey: .jid)
- id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased()
- displayName = try values.decode(String.self, forKey: .displayName)
- lastMessage = try values.decode(String.self, forKey: .lastMessage)
- lastActivity = try values.decode(Date.self, forKey: .lastActivity)
- unreadCount = try values.decode(Int.self, forKey: .unreadCount)
- isOnline = try values.decode(Bool.self, forKey: .isOnline)
- isPinned = try values.decode(Bool.self, forKey: .isPinned)
- encryptionPreference = try values.decodeIfPresent(
- EncryptionPreference.self,
- forKey: .encryptionPreference
- ) ?? .inheritGlobal
- kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct
- groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname)
- isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false
- shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false
- occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0)
- invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy)
- }
-
var isGroup: Bool { kind == .group }
var initials: String {
@@ -115,6 +78,15 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
}
}
+/// A row in the cross-account inbox. JID alone is not a stable identity when
+/// two logged-in accounts have the same contact or a self-chat.
+struct AccountConversation: Identifiable {
+ let account: AccountConfiguration
+ let conversation: Conversation
+
+ var id: String { "\(account.normalizedJID)|\(conversation.jid)" }
+}
+
private extension String {
var nilIfBlank: String? {
trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
diff --git a/Sources/Shared/Models/DeviceResource.swift b/Sources/Shared/Models/DeviceResource.swift
new file mode 100644
index 0000000..3cb7dcb
--- /dev/null
+++ b/Sources/Shared/Models/DeviceResource.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+/// Stable, per-installation XMPP resource. Two devices that both fall back to
+/// the default must never share a resource: an XMPP server resolves a resource
+/// collision with a `conflict` stream error, which disconnects the older
+/// session. A UUID persisted in UserDefaults keeps the value stable across
+/// launches (so presence and stream-management resume stay consistent) while
+/// remaining distinct between macOS and iOS installs.
+enum DeviceResource {
+ /// The legacy hard-coded default that older Luma builds sent from every
+ /// device. Treated as "automatic" so existing accounts migrate without the
+ /// user having to re-enter credentials.
+ static let legacyDefault = "Luma"
+
+ private static let storageKey = "app.luma.xmpp.resource"
+
+ static var `default`: String {
+ let defaults = UserDefaults.standard
+ if let existing = defaults.string(forKey: storageKey), !existing.isEmpty {
+ return existing
+ }
+ let suffix = UUID().uuidString
+ .replacingOccurrences(of: "-", with: "")
+ .prefix(8)
+ .lowercased()
+ let resource = "Luma-\(suffix)"
+ defaults.set(resource, forKey: storageKey)
+ return resource
+ }
+}
diff --git a/Sources/Shared/Models/EmojiCatalog.swift b/Sources/Shared/Models/EmojiCatalog.swift
new file mode 100644
index 0000000..5883659
--- /dev/null
+++ b/Sources/Shared/Models/EmojiCatalog.swift
@@ -0,0 +1,200 @@
+import Foundation
+
+/// A category of emojis shown in the emoji picker.
+struct EmojiCategory: Identifiable, Equatable, Sendable {
+ let title: String
+ let symbol: String
+ let emojis: [String]
+
+ var id: String { title }
+}
+
+/// Curated, widely-supported emoji set grouped into categories. Used by both
+/// the reaction picker and the composer's emoji picker.
+enum EmojiCatalog {
+ static let categories: [EmojiCategory] = [
+ EmojiCategory(
+ title: "Смайлы",
+ symbol: "face.smiling",
+ emojis: [
+ "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🥲", "☺️",
+ "😊", "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", "😗",
+ "😙", "😚", "😋", "😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓",
+ "😎", "🥸", "🤩", "🥳", "😏", "😒", "😞", "😔", "😟", "😕",
+ "🙁", "☹️", "😣", "😖", "😫", "😩", "🥺", "😢", "😭", "😤",
+ "😠", "😡", "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰",
+ "😥", "😓", "🤗", "🤔", "🫣", "🤭", "🫢", "🤫", "🤥", "😶",
+ "😐", "😑", "😬", "🙄", "😯", "😦", "😧", "😮", "😲", "🥱",
+ "😴", "🤤", "😪", "😵", "🤐", "🥴", "🤢", "🤮", "🤧", "😷",
+ "🤒", "🤕", "🤑", "🤠", "😈", "👿", "👹", "👺", "🤡", "💩",
+ "👻", "💀", "☠️", "👽", "👾", "🤖", "🎃", "😺", "😸", "😹",
+ "😻", "😼", "😽", "🙀", "😿", "😾", "❤️", "🧡", "💛", "💚",
+ "💙", "💜", "🖤", "🤍", "🤎", "💔", "❤️🔥", "❤️🩹", "💕", "💞",
+ "💓", "💗", "💖", "💘", "💝", "💟", "❣️", "💌", "💢", "💥",
+ "💫", "💦", "💨", "🕳️", "💬", "💭", "🗯️",
+ ]
+ ),
+ EmojiCategory(
+ title: "Жесты",
+ symbol: "hand.raised",
+ emojis: [
+ "👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞",
+ "🫰", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️",
+ "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "🫶", "👐",
+ "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦵", "🦶",
+ "👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁️", "👅", "👄",
+ "👶", "🧒", "👦", "👧", "🧑", "👱", "👨", "🧔", "👩", "🧓",
+ "👴", "👵", "🙍", "🙎", "🙅", "🙆", "💁", "🙋", "🧏", "🙇",
+ "🤦", "🤷", "💆", "💇", "🚶", "🧍", "🧎", "🏃", "💃", "🕺",
+ "🧖", "🧗", "🤺", "🏇", "⛷️", "🏂", "🏌️", "🏄", "🚣", "🏊",
+ "⛹️", "🏋️", "🚴", "🚵", "🤸", "🤼", "🤽", "🤾", "🤹", "🧘",
+ "🛀", "🛌", "🧑🤝🧑", "👭", "👫", "👬", "💏", "💑", "👪",
+ ]
+ ),
+ EmojiCategory(
+ title: "Животные и природа",
+ symbol: "leaf",
+ emojis: [
+ "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐻❄️", "🐨",
+ "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🙈", "🙉", "🙊", "🐔",
+ "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺",
+ "🐗", "🐴", "🦄", "🐝", "🪱", "🐛", "🦋", "🐌", "🐞", "🐜",
+ "🪰", "🪲", "🪳", "🦟", "🦗", "🕷️", "🕸️", "🦂", "🐢", "🐍",
+ "🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠",
+ "🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍",
+ "🦧", "🦣", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🦬",
+ "🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌",
+ "🐕", "🐩", "🦮", "🐈", "🐈⬛", "🪶", "🐓", "🦃", "🦤", "🦚",
+ "🦜", "🦢", "🦩", "🕊️", "🐇", "🦝", "🦨", "🦡", "🦫", "🦦",
+ "🦥", "🐁", "🐀", "🐿️", "🦔", "🐾", "🐉", "🐲", "🌵", "🎄",
+ "🌲", "🌳", "🌴", "🌱", "🌿", "☘️", "🍀", "🎍", "🎋", "🍃",
+ "🍂", "🍁", "🍄", "🐚", "🌾", "💐", "🌷", "🌹", "🥀", "🌺",
+ "🌸", "🌼", "🌻", "🌞", "🌝", "🌛", "🌜", "🌚", "🌕", "🌖",
+ "🌗", "🌘", "🌑", "🌒", "🌓", "🌔", "🌙", "🌎", "🌍", "🌏",
+ "💫", "⭐", "🌟", "✨", "⚡", "☄️", "💥", "🔥", "🌪️", "🌈",
+ "☀️", "🌤️", "⛅", "🌥️", "☁️", "🌦️", "🌧️", "⛈️", "🌩️", "🌨️",
+ "❄️", "☃️", "⛄", "🌬️", "💨", "💧", "💦", "☔", "☂️", "🌊", "🌫️",
+ ]
+ ),
+ EmojiCategory(
+ title: "Еда и напитки",
+ symbol: "fork.knife",
+ emojis: [
+ "🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐",
+ "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑",
+ "🥦", "🥬", "🥒", "🌶️", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅",
+ "🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳",
+ "🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔",
+ "🍟", "🍕", "🫓", "🥪", "🥙", "🧆", "🌮", "🌯", "🫔", "🥗",
+ "🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟",
+ "🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡",
+ "🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬",
+ "🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "🫖",
+ "☕", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷",
+ "🥃", "🍸", "🍹", "🧉", "🍾", "🧊", "🥄", "🍴", "🍽️", "🥣",
+ "🥡", "🥢",
+ ]
+ ),
+ EmojiCategory(
+ title: "Активности",
+ symbol: "soccerball",
+ emojis: [
+ "⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱",
+ "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳",
+ "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛼", "🛷",
+ "⛸️", "🥌", "🎿", "⛷️", "🏂", "🪂", "🏋️", "🤼", "🤸", "⛹️",
+ "🤺", "🤾", "🏌️", "🏇", "🧘", "🏄", "🏊", "🤽", "🚣", "🧗",
+ "🚵", "🚴", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖️", "🏵️", "🎗️",
+ "🎫", "🎟️", "🎪", "🤹", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧",
+ "🎼", "🎹", "🥁", "🪘", "🎷", "🎺", "🪗", "🎸", "🪕", "🎻",
+ "🎲", "♟️", "🎯", "🎳", "🎮", "🎰", "🧩",
+ ]
+ ),
+ EmojiCategory(
+ title: "Путешествия",
+ symbol: "airplane",
+ emojis: [
+ "🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐",
+ "🛻", "🚚", "🚛", "🚜", "🛵", "🏍️", "🛺", "🚲", "🛴", "🛹",
+ "🚨", "🚔", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🚃", "🚋",
+ "🚞", "🚝", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚉",
+ "✈️", "🛫", "🛬", "🛩️", "💺", "🛰️", "🚀", "🛸", "🚁", "🛶",
+ "⛵", "🚤", "🛥️", "🛳️", "⛴️", "🚢", "⚓", "🪝", "🚧", "🚦",
+ "🚥", "🚏", "🗺️", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟️", "🎡",
+ "🎢", "🎠", "⛲", "⛱️", "🏖️", "🏝️", "🏜️", "🌋", "⛰️", "🏔️",
+ "🗻", "🏕️", "⛺", "🛖", "🏠", "🏡", "🏘️", "🏚️", "🏗️", "🏭",
+ "🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩",
+ "💒", "🏛️", "⛪", "🕌", "🕍", "🛕", "🕋", "⛩️", "🛤️", "🛣️",
+ "🗾", "🎑", "🏞️", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆",
+ "🏙️", "🌃", "🌌", "🌉", "🌁",
+ ]
+ ),
+ EmojiCategory(
+ title: "Предметы",
+ symbol: "lightbulb",
+ emojis: [
+ "⌚", "📱", "📲", "💻", "⌨️", "🖥️", "🖨️", "🖱️", "🖲️", "🕹️",
+ "🗜️", "💽", "💾", "💿", "📀", "📼", "📷", "📸", "📹", "🎥",
+ "📽️", "🎞️", "📞", "☎️", "📟", "📠", "📺", "📻", "🎙️", "🎚️",
+ "🎛️", "🧭", "⏱️", "⏲️", "⏰", "🕰️", "⌛", "⏳", "📡", "🔋",
+ "🔌", "💡", "🔦", "🕯️", "🪔", "🧯", "🛢️", "💸", "💵", "💴",
+ "💶", "💷", "🪙", "💰", "💳", "💎", "⚖️", "🪜", "🧰", "🪛",
+ "🔧", "🔨", "⚒️", "🛠️", "⛏️", "🪚", "🔩", "⚙️", "🪤", "🧱",
+ "⛓️", "🧲", "🔫", "💣", "🧨", "🪓", "🔪", "🗡️", "⚔️", "🛡️",
+ "🚬", "⚰️", "🪦", "⚱️", "🏺", "🔮", "📿", "🧿", "💈", "⚗️",
+ "🔭", "🔬", "🕳️", "🩹", "🩺", "💊", "💉", "🩸", "🧬", "🦠",
+ "🧫", "🧪", "🌡️", "🧹", "🪠", "🧺", "🧻", "🚽", "🚰", "🚿",
+ "🛁", "🛀", "🧼", "🪥", "🪒", "🧽", "🪣", "🧴", "🛎️", "🔑",
+ "🗝️", "🚪", "🪑", "🛋️", "🛏️", "🛌", "🧸", "🪆", "🖼️", "🪞",
+ "🪟", "🛍️", "🛒", "🎁", "🎈", "🎏", "🎀", "🪄", "🪅", "🎊",
+ "🎉", "🎎", "🏮", "🎐", "🧧", "✉️", "📩", "📨", "📧", "💌",
+ "📥", "📤", "📦", "🏷️", "🪧", "📪", "📫", "📬", "📭", "📮",
+ "📯", "📜", "📃", "📄", "📑", "🧾", "📊", "📈", "📉", "🗒️",
+ "🗓️", "📆", "📅", "🗑️", "📇", "🗃️", "🗳️", "🗄️", "📋", "📁",
+ "📂", "🗂️", "🗞️", "📰", "📓", "📔", "📒", "📕", "📗", "📘",
+ "📙", "📚", "📖", "🔖", "🧷", "🔗", "📎", "🖇️", "📐", "📏",
+ "🧮", "📌", "📍", "✂️", "🖊️", "🖋️", "✒️", "🖌️", "🖍️", "📝",
+ "✏️", "🔍", "🔎", "🔏", "🔐", "🔒", "🔓",
+ ]
+ ),
+ EmojiCategory(
+ title: "Символы",
+ symbol: "heart",
+ emojis: [
+ "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔",
+ "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "♥️",
+ "♠️", "♣️", "♦️", "🃏", "🀄", "🎴", "🔇", "🔈", "🔉", "🔊",
+ "📢", "📣", "📯", "🔔", "🔕", "🎼", "🎵", "🎶", "🎙️", "🎚️",
+ "🎛️", "🎤", "🎧", "📻", "🎷", "🪗", "🎸", "🎹", "🎺", "🎻",
+ "🥁", "🪘", "📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌",
+ "💻", "🖥️", "🖨️", "⌨️", "🖱️", "🖲️", "💽", "💾", "💿", "📀",
+ "🧮", "🎥", "🎞️", "📽️", "🎬", "📺", "📷", "📸", "📹", "📼",
+ "🔍", "🔎", "🕯️", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖",
+ "📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰",
+ "🗞️", "📑", "🔖", "🏷️", "💰", "🪙", "💴", "💵", "💶", "💷",
+ "💸", "💳", "🧾", "✉️", "📧", "📨", "📩", "📤", "📥", "📦",
+ "📫", "📪", "📬", "📭", "📮", "🗳️", "✏️", "✒️", "🖋️", "🖊️",
+ "🖌️", "🖍️", "📝", "💼", "📁", "📂", "🗂️", "📅", "📆", "🗒️",
+ "🗓️", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇️",
+ "📏", "📐", "✂️", "🗃️", "🗄️", "🗑️", "🔒", "🔓", "🔏", "🔐",
+ "🔑", "🗝️", "🔨", "🪓", "⛏️", "⚒️", "🛠️", "🗡️", "⚔️", "🔫",
+ "🪃", "🏹", "🛡️", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜️", "⚖️",
+ "🦯", "🔗", "⛓️", "🪝", "🧰", "🧲", "🪜", "⚗️", "🧪", "🧫",
+ "🧬", "🔬", "🔭", "📡", "💉", "🩸", "💊", "🩹", "🩺", "🚪",
+ "🛏️", "🛋️", "🪑", "🚽", "🪠", "🚿", "🛁", "🪒", "🧴", "🧷",
+ "🧹", "🧺", "🧻", "🧼", "🪥", "🧽", "🧯", "🛒", "🚬", "⚰️",
+ "🪦", "⚱️", "🗿", "🪧", "🪪", "🆗", "🆕", "🆒", "🆓", "🆙",
+ "🆖", "🆚", "🈁", "🈂️", "🈷️", "🈶", "🈯", "🉐", "🈹", "🈚",
+ "🈲", "🉑", "🈸", "🈴", "🈳", "㊗️", "㊙️", "🈺", "🈵", "🔴",
+ "🟠", "🟡", "🟢", "🔵", "🟣", "🟤", "⚫", "⚪", "🟥", "🟧",
+ "🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜", "◼️", "◻️", "◾",
+ "◽", "▪️", "▫️", "🔶", "🔷", "🔸", "🔹", "🔺", "🔻", "💠",
+ "🔘", "🔳", "🔲",
+ ]
+ ),
+ ]
+
+ static var allEmojis: [String] {
+ categories.flatMap(\.emojis)
+ }
+}
diff --git a/Sources/Shared/Models/MessageReaction.swift b/Sources/Shared/Models/MessageReaction.swift
index 6090b53..4fc2cec 100644
--- a/Sources/Shared/Models/MessageReaction.swift
+++ b/Sources/Shared/Models/MessageReaction.swift
@@ -80,7 +80,7 @@ enum MessageReactionPolicy {
extension ChatMessage {
var reactionIdentifier: String? {
- isGroupMessage ? stanzaID : id
+ isGroupMessage ? stanzaID : clientID
}
var canBeReactedTo: Bool {
diff --git a/Sources/Shared/Models/MessageReplySwipePolicy.swift b/Sources/Shared/Models/MessageReplySwipePolicy.swift
index 0eb0230..e8e23d8 100644
--- a/Sources/Shared/Models/MessageReplySwipePolicy.swift
+++ b/Sources/Shared/Models/MessageReplySwipePolicy.swift
@@ -2,30 +2,62 @@ import CoreGraphics
struct MessageReplySwipePolicy {
static let triggerDistance: CGFloat = 64
+ /// Horizontal travel required before the indicator starts following the
+ /// finger; keeps per-cell state untouched during ordinary scrolling.
+ static let activationDistance: CGFloat = 24
static let maximumOffset: CGFloat = 76
- static let horizontalDominance: CGFloat = 1.4
+ /// Ratio required to lock the gesture into a reply swipe. Mostly vertical
+ /// timeline scrolls (even diagonal ones) never reach it.
+ static let lockDominance: CGFloat = 1.6
+ /// Relaxed ratio once locked, so the finger can drift while finishing
+ /// the swipe without it being cancelled by the scroll view.
+ static let followDominance: CGFloat = 1.05
- static func offset(for translation: CGSize) -> CGFloat {
- guard isHorizontal(translation) else { return 0 }
- let distance = min(abs(translation.width) * 0.72, maximumOffset)
+ /// Whether this translation can lock the gesture as a horizontal swipe.
+ /// Both directions are accepted, and the lock is only available early:
+ /// once the finger has travelled mostly vertically the scroll view owns
+ /// the touch and the swipe must never activate for the rest of that
+ /// gesture.
+ static func canLock(_ translation: CGSize) -> Bool {
+ abs(translation.height) <= 24
+ && isHorizontal(translation, dominance: lockDominance)
+ && abs(translation.width) >= activationDistance * 0.5
+ }
+
+ static func isHorizontal(_ translation: CGSize) -> Bool {
+ isHorizontal(translation, dominance: lockDominance)
+ }
+
+ /// Visual offset of the bubble: negative for a left swipe, positive for
+ /// a right swipe, zero while the movement is not clearly horizontal.
+ static func offset(locked: Bool, translation: CGSize) -> CGFloat {
+ let dominance = locked ? followDominance : lockDominance
+ guard isHorizontal(translation, dominance: dominance) else { return 0 }
+ let width = abs(translation.width)
+ guard width >= activationDistance else { return 0 }
+ let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset)
return translation.width < 0 ? -distance : distance
}
static func shouldReply(
+ locked: Bool,
translation: CGSize,
predictedEndTranslation: CGSize
) -> Bool {
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
? predictedEndTranslation
: translation
- return isHorizontal(candidate) && abs(candidate.width) >= triggerDistance
+ let dominance = locked ? followDominance : lockDominance
+ return isHorizontal(candidate, dominance: dominance)
+ && abs(candidate.width) >= triggerDistance
}
static func progress(for offset: CGFloat) -> CGFloat {
min(1, abs(offset) / triggerDistance)
}
- private static func isHorizontal(_ translation: CGSize) -> Bool {
- abs(translation.width) > abs(translation.height) * horizontalDominance
+ private static func isHorizontal(_ translation: CGSize, dominance: CGFloat) -> Bool {
+ abs(translation.width) > abs(translation.height) * dominance
}
}
+
diff --git a/Sources/Shared/Models/OMEMODevice.swift b/Sources/Shared/Models/OMEMODevice.swift
index be68db1..ff218f9 100644
--- a/Sources/Shared/Models/OMEMODevice.swift
+++ b/Sources/Shared/Models/OMEMODevice.swift
@@ -14,8 +14,14 @@ struct OMEMODevice: Identifiable, Hashable, Sendable {
let trust: Trust
let isActive: Bool
let isOwn: Bool
+ let isOMEMO2: Bool
- var id: String { "\(jid)|\(deviceID)" }
+ var id: String { "\(jid)|\(deviceID)|\(isOMEMO2 ? "omemo2" : "legacy")" }
+
+ /// Short protocol label for the encryption screen badge.
+ var protocolName: String {
+ isOMEMO2 ? "OMEMO 2" : "OMEMO"
+ }
var formattedFingerprint: String {
fingerprint.chunked(every: 8).joined(separator: " ")
diff --git a/Sources/Shared/Models/OlderHistoryScrollPolicy.swift b/Sources/Shared/Models/OlderHistoryScrollPolicy.swift
new file mode 100644
index 0000000..95e7c87
--- /dev/null
+++ b/Sources/Shared/Models/OlderHistoryScrollPolicy.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+/// One interactive backward-history page handed to the UI: whether older
+/// history remains and the cursor the next request must use.
+struct OlderHistoryScrollPage: Equatable, Sendable {
+ let hasMore: Bool
+ let nextBefore: String?
+}
+
+/// Interactive scroll-back policy. The cursor for the next page must come
+/// from the server's RSM of the page that was just returned, not from
+/// the oldest local message: a page can consist entirely of reactions,
+/// retractions or already-known stanzas, which insert no new message rows, so
+/// a locally computed anchor would never advance and every subsequent scroll
+/// would re-request the same page forever (spinner spins, nothing loads).
+enum OlderHistoryScrollPolicy {
+ /// - Parameter complete: the server's flag.
+ /// - Parameter pageFirstID: RSM UID of the returned page — the
+ /// oldest item in it, i.e. the anchor for the next, older page.
+ static func page(
+ complete: Bool,
+ pageFirstID: String?
+ ) -> OlderHistoryScrollPage {
+ let nextBefore = ArchiveSyncCheckpoint.normalizedCursor(pageFirstID)
+ // An empty page (or one without RSM) cannot advance the cursor, so it
+ // must never report "more" — otherwise the UI would loop on it.
+ let hasMore = !complete && nextBefore != nil
+ return OlderHistoryScrollPage(hasMore: hasMore, nextBefore: nextBefore)
+ }
+}
diff --git a/Sources/Shared/Models/SASLMechanismPreference.swift b/Sources/Shared/Models/SASLMechanismPreference.swift
new file mode 100644
index 0000000..67e0125
--- /dev/null
+++ b/Sources/Shared/Models/SASLMechanismPreference.swift
@@ -0,0 +1,49 @@
+import Foundation
+
+/// Order in which Luma prefers SASL mechanisms during authentication.
+/// Channel-binding SCRAM first (tls-exporter / tls-server-end-point prove
+/// the exchange runs over the client's own TLS channel, defeating MITM
+/// relays), then the strongest plain SCRAM variant, and PLAIN as the
+/// last-resort fallback. The server must advertise a mechanism for it to be
+/// eligible — Martin's `SaslModule.guessSaslMechanism` walks exactly this
+/// order, and the PLUS mechanisms additionally gate on available binding
+/// data in `isAllowedToUse`.
+enum SASLMechanismPreference {
+ static let order: [String] = [
+ "SCRAM-SHA-512-PLUS",
+ "SCRAM-SHA-256-PLUS",
+ "SCRAM-SHA-1-PLUS",
+ "SCRAM-SHA-512",
+ "SCRAM-SHA-256",
+ "SCRAM-SHA-1",
+ "PLAIN",
+ ]
+
+ /// The most secure mechanism from `order` that the server advertises,
+ /// or nil when no password-based mechanism is offered. When
+ /// `allowsChannelBinding` is false the -PLUS variants are skipped, since
+ /// without binding data the exchange would fail.
+ static func selected(
+ among offered: [String],
+ allowsChannelBinding: Bool = true
+ ) -> String? {
+ order.first { candidate in
+ guard offered.contains(candidate) else { return false }
+ return allowsChannelBinding || !candidate.hasSuffix("-PLUS")
+ }
+ }
+
+ /// Mechanisms that send credentials in a way a TLS MITM can replay or
+ /// read directly; shown with a warning on the server-information screen.
+ static let weakMechanisms: Set = ["PLAIN", "OAUTHBEARER"]
+
+ /// True when `mechanism` is the strongest one available among
+ /// `offered`: the server offers nothing better than the chosen one.
+ static func isStrongestAvailable(_ mechanism: String, among offered: [String]) -> Bool {
+ guard let chosenRank = order.firstIndex(of: mechanism) else { return false }
+ for stronger in order[.. acknowledged ? sent - acknowledged : 0
+ }
+ }
+
+ struct Capability: Equatable, Sendable, Identifiable {
+ let title: String
+ let detail: String
+ let status: CapabilityStatus
+
+ var id: String { title }
+ }
+
+ let serverDomain: String
+ let software: Software
+ let identities: [Identity]
+ let serverFeatures: [String]
+ let accountFeatures: [String]
+ let conferenceServers: [ConferenceServer]
+ let externalServices: [ExternalService]
+ let connectionStats: ConnectionStats
+ let saslMethods: [String]
+ /// SASL2 (RFC 9050) mechanisms advertised in ``.
+ let sasl2Methods: [String]
+ /// XEP-0440 channel-binding types advertised by the server, used to
+ /// detect MITM attacks on the TLS layer during SCRAM-PLUS.
+ let channelBindingTypes: [String]
+ /// The channel-binding type actually used for this connection's SASL
+ /// exchange, when a -PLUS mechanism was selected.
+ let usedChannelBindingType: String?
+ /// True when the server included its XEP-0474 downgrade-protection
+ /// hash in the SCRAM exchange (`h` attribute of the server-first message).
+ let supportsSCRAMDowngradeProtection: Bool
+ /// Raw name of the SASL mechanism used for this connection's auth
+ /// (without the channel-binding suffix), highlighted in the method lists.
+ let usedSASLMechanism: String?
+ /// TLS version negotiated with the server during this connection
+ /// (probed from a parallel handshake when the library does not expose it).
+ let tlsVersion: String?
+ /// Cipher suite negotiated alongside the TLS version.
+ let tlsCipher: String?
+ /// SASL mechanism actually selected for authentication (the strongest
+ /// one the server advertises).
+ let saslMechanism: String?
+ let supportsStreamManagement: Bool
+ let supportsCarbons: Bool
+ let supportsClientState: Bool
+ let supportsHTTPUpload: Bool
+ let supportsRosterVersioning: Bool
+ let supportsRosterPreApproval: Bool
+
+ /// The curated XEP list Monal shows, with a status derived from the server
+ /// and account disco features plus the connection-level flags.
+ var capabilities: [Capability] {
+ let server = Set(serverFeatures)
+ let account = Set(accountFeatures)
+ let supportsPubSub = account.contains { $0.hasPrefix("http://jabber.org/protocol/pubsub") }
+ return [
+ Capability(
+ title: "XEP-0163 Personal Eventing Protocol",
+ detail: "Публикация событий состояния аккаунта (аватары, статусы) через publish-subscribe.",
+ status: supportsPubSub ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0191 Blocking Command",
+ detail: "Расширение XMPP для блокировки контактов.",
+ status: server.contains("urn:xmpp:blocking") ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0198 Stream Management",
+ detail: "Возобновление потока при обрыве соединения — быстрее переподключение и экономия заряда.",
+ status: supportsStreamManagement ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0199 XMPP Ping",
+ detail: "Пинг уровня приложения поверх XML-потока.",
+ status: server.contains("urn:xmpp:ping") ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0215 External Service Discovery",
+ detail: "Обнаружение внешних сервисов (STUN/TURN) для аудио- и видеозвонков.",
+ status: server.contains("urn:xmpp:extdisco:2") ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0237 Roster Versioning",
+ detail: "Версионирование ростера, чтобы сервер не отправлял его повторно, если он не менялся.",
+ status: supportsRosterVersioning ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0280 Message Carbons",
+ detail: "Синхронизация сообщений между всеми вашими устройствами.",
+ status: supportsCarbons ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0313 Message Archive Management",
+ detail: "Доступ к архиву сообщений на сервере.",
+ status: account.contains("urn:xmpp:mam:2") ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0352 Client State Indication",
+ detail: "Указывает, активно ли устройство. Экономит заряд.",
+ status: supportsClientState ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0357 Push Notifications",
+ detail: "Push-уведомления через Apple даже когда приложение выгружено.",
+ status: account.contains("urn:xmpp:push:0") ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0363 HTTP File Upload",
+ detail: "Загрузка файлов на сервер для обмена с собеседниками.",
+ status: supportsHTTPUpload ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0379 Pre-Authenticated Roster Subscription",
+ detail: "Ссылки для предварительно аутентифицированной подписки на присутствие.",
+ status: supportsRosterPreApproval ? .success : .error
+ ),
+ Capability(
+ title: "XEP-0474 SASL SCRAM Downgrade Protection",
+ detail: supportsSCRAMDowngradeProtection
+ ? "Сервер включил downgrade-protection hash в SCRAM-обмен (атрибут h): списки SASL-механизмов и channel-binding защищены от подмены MITM."
+ : "Сервер не включил downgrade-protection hash в SCRAM-обмен (или вход выполнен через PLAIN): списки механизмов и channel-binding не защищены от подмены.",
+ status: supportsSCRAMDowngradeProtection ? .success : .error
+ ),
+ ]
+ }
+}
diff --git a/Sources/Shared/Models/VideoNoteRotationPolicy.swift b/Sources/Shared/Models/VideoNoteRotationPolicy.swift
new file mode 100644
index 0000000..d811a41
--- /dev/null
+++ b/Sources/Shared/Models/VideoNoteRotationPolicy.swift
@@ -0,0 +1,48 @@
+import CoreGraphics
+
+#if os(iOS)
+ import UIKit
+#endif
+
+/// Clockwise rotation, in degrees, applied to the video-note capture so the
+/// recorded QuickTime track plays upright. The camera sensor is
+/// landscape-native: per Apple's videoOrientation<->videoRotationAngle
+/// equivalence a portrait recording needs a 90-degree clockwise rotation
+/// (a QuickTime track matrix), never 0 — 0 leaves the circle sideways.
+enum VideoNoteRotationPolicy {
+ enum InterfaceOrientation: Equatable, Sendable {
+ case portrait
+ case portraitUpsideDown
+ case landscapeLeft
+ case landscapeRight
+ }
+
+ static func angle(for orientation: InterfaceOrientation) -> CGFloat {
+ switch orientation {
+ case .portrait: return 90
+ case .portraitUpsideDown: return 270
+ case .landscapeLeft: return 180
+ case .landscapeRight: return 0
+ }
+ }
+
+ #if os(iOS)
+ /// The orientation of the interface the user actually sees. The device
+ /// sensor (UIDevice.current.orientation) can report landscape or
+ /// face-up while the app stays portrait, which is exactly how a
+ /// portrait circle ended up encoded sideways.
+ @MainActor
+ static var currentInterfaceOrientation: InterfaceOrientation {
+ let orientation = UIApplication.shared.connectedScenes
+ .compactMap { $0 as? UIWindowScene }
+ .first { $0.activationState == .foregroundActive }?
+ .effectiveGeometry.interfaceOrientation
+ switch orientation {
+ case .portraitUpsideDown: return .portraitUpsideDown
+ case .landscapeLeft: return .landscapeLeft
+ case .landscapeRight: return .landscapeRight
+ default: return .portrait
+ }
+ }
+ #endif
+}
diff --git a/Sources/Shared/Persistence/AccountPreferences.swift b/Sources/Shared/Persistence/AccountPreferences.swift
index 5485161..3fdb73c 100644
--- a/Sources/Shared/Persistence/AccountPreferences.swift
+++ b/Sources/Shared/Persistence/AccountPreferences.swift
@@ -3,6 +3,7 @@ import Foundation
final class AccountPreferences {
private let defaults: UserDefaults
private let accountKey = "luma.active-account.v1"
+ private let accountsKey = "luma.accounts.v1"
private let encryptionSettingsKey = "luma.encryption-settings.v1"
private let chatStateSettingsKey = "luma.chat-state-settings.v1"
@@ -15,6 +16,39 @@ final class AccountPreferences {
return try? JSONDecoder().decode(AccountConfiguration.self, from: data)
}
+ /// Returns every configured account. The original single-account key is
+ /// migrated lazily so existing installations keep working.
+ func loadAccounts() -> [AccountConfiguration] {
+ if let data = defaults.data(forKey: accountsKey),
+ let accounts = try? JSONDecoder().decode([AccountConfiguration].self, from: data) {
+ return accounts
+ }
+ guard let account = load() else { return [] }
+ return [account]
+ }
+
+ func saveAccounts(_ accounts: [AccountConfiguration]) throws {
+ let normalized = accounts.reduce(into: [String: AccountConfiguration]()) { result, account in
+ result[account.normalizedJID] = account
+ }.values.sorted { $0.normalizedJID < $1.normalizedJID }
+ defaults.set(try JSONEncoder().encode(normalized), forKey: accountsKey)
+ if let active = load(), normalized.contains(where: { $0.normalizedJID == active.normalizedJID }) {
+ try save(active)
+ } else if let first = normalized.first {
+ try save(first)
+ } else {
+ clear()
+ }
+ }
+
+ func add(_ account: AccountConfiguration) throws {
+ try saveAccounts(loadAccounts() + [account])
+ }
+
+ func remove(jid: String) throws {
+ try saveAccounts(loadAccounts().filter { $0.normalizedJID != jid.lowercased() })
+ }
+
func save(_ account: AccountConfiguration) throws {
let data = try JSONEncoder().encode(account)
defaults.set(data, forKey: accountKey)
diff --git a/Sources/Shared/Persistence/ArchiveMetadataRecord.swift b/Sources/Shared/Persistence/ArchiveMetadataRecord.swift
new file mode 100644
index 0000000..aff9d47
--- /dev/null
+++ b/Sources/Shared/Persistence/ArchiveMetadataRecord.swift
@@ -0,0 +1,37 @@
+import Foundation
+import SwiftData
+
+/// Codable representation of a single durable MAM checkpoint, keyed by the
+/// archive it belongs to (account or a specific MUC).
+struct MAMCheckpointEntry: Codable, Equatable, Sendable {
+ let key: MAMArchiveKey
+ let checkpoint: MAMArchiveCheckpoint
+}
+
+/// Singleton per-account metadata that used to live in the JSON snapshot:
+/// locally deleted message IDs, roster contact JIDs and MAM checkpoints.
+@Model
+final class ArchiveMetadata {
+ @Attribute(.unique) var accountJID: String
+ var locallyDeletedMessageIDs: [String]
+ var rosterContactJIDs: [String]
+ var lastSuccessfulMAMSync: Date?
+ var lastSuccessfulMAMCursor: String?
+ var mamCheckpoints: [MAMCheckpointEntry]
+
+ init(
+ accountJID: String,
+ locallyDeletedMessageIDs: [String] = [],
+ rosterContactJIDs: [String] = [],
+ lastSuccessfulMAMSync: Date? = nil,
+ lastSuccessfulMAMCursor: String? = nil,
+ mamCheckpoints: [MAMCheckpointEntry] = []
+ ) {
+ self.accountJID = accountJID
+ self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
+ self.rosterContactJIDs = rosterContactJIDs
+ self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
+ self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
+ self.mamCheckpoints = mamCheckpoints
+ }
+}
diff --git a/Sources/Shared/Persistence/ArchiveStore.swift b/Sources/Shared/Persistence/ArchiveStore.swift
new file mode 100644
index 0000000..eb11a2d
--- /dev/null
+++ b/Sources/Shared/Persistence/ArchiveStore.swift
@@ -0,0 +1,184 @@
+import Foundation
+import SwiftData
+
+/// SwiftData-backed replacement for the legacy JSON `ChatArchive`. Owns the
+/// per-account `ModelContainer`/`ModelContext`, imports the old JSON snapshot
+/// once, and exposes load/save/erase.
+@MainActor
+final class ArchiveStore {
+ struct Loaded {
+ var conversations: [Conversation]
+ var messages: [ChatMessage]
+ var locallyDeletedMessageIDs: Set
+ var rosterContactJIDs: Set
+ var lastSuccessfulMAMSync: Date?
+ var lastSuccessfulMAMCursor: String?
+ var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
+ }
+
+ let container: ModelContainer
+ let context: ModelContext
+ private let accountJID: String
+ private let storeURL: URL
+ private let legacyJSONURL: URL
+ private(set) var metadata: ArchiveMetadata?
+
+ init(accountJID: String) throws {
+ self.accountJID = accountJID
+ let fileManager = FileManager.default
+ let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? fileManager.temporaryDirectory
+ let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
+ try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ let hash = Self.stableHash(accountJID)
+ storeURL = directory.appendingPathComponent("\(hash).store")
+ legacyJSONURL = directory.appendingPathComponent("\(hash).json")
+
+ let schema = Schema([
+ Conversation.self,
+ ChatMessage.self,
+ ArchiveMetadata.self,
+ ])
+ let configuration = ModelConfiguration(schema: schema, url: storeURL)
+ container = try ModelContainer(for: schema, configurations: [configuration])
+ context = ModelContext(container)
+ context.autosaveEnabled = false
+ applyDataProtection()
+
+ migrateLegacyJSONIfNeeded()
+ purgeLocallyDeletedMessages()
+ }
+
+ func load() -> Loaded {
+ let conversationSort = [SortDescriptor(\Conversation.lastActivity, order: .reverse)]
+ let conversations = (try? context.fetch(FetchDescriptor(sortBy: conversationSort))) ?? []
+ let messages = (try? context.fetch(FetchDescriptor())) ?? []
+
+ let metadata = fetchMetadata()
+ let mamCheckpoints = Dictionary(
+ metadata.mamCheckpoints.map { ($0.key, $0.checkpoint) },
+ uniquingKeysWith: { first, _ in first }
+ )
+
+ return Loaded(
+ conversations: conversations,
+ messages: messages,
+ locallyDeletedMessageIDs: Set(metadata.locallyDeletedMessageIDs),
+ rosterContactJIDs: Set(metadata.rosterContactJIDs),
+ lastSuccessfulMAMSync: metadata.lastSuccessfulMAMSync,
+ lastSuccessfulMAMCursor: metadata.lastSuccessfulMAMCursor,
+ mamCheckpoints: mamCheckpoints
+ )
+ }
+
+ /// Persists the passed metadata plus all pending model mutations.
+ func save(
+ locallyDeletedMessageIDs: Set,
+ rosterContactJIDs: Set,
+ lastSuccessfulMAMSync: Date?,
+ lastSuccessfulMAMCursor: String?,
+ mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
+ ) throws {
+ let metadata = fetchMetadata()
+ metadata.locallyDeletedMessageIDs = Array(locallyDeletedMessageIDs)
+ metadata.rosterContactJIDs = Array(rosterContactJIDs)
+ metadata.lastSuccessfulMAMSync = lastSuccessfulMAMSync
+ metadata.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(lastSuccessfulMAMCursor)
+ metadata.mamCheckpoints = mamCheckpoints.map {
+ MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
+ }
+ try context.save()
+ applyDataProtection()
+ }
+
+ func erase() throws {
+ try context.delete(model: Conversation.self)
+ try context.delete(model: ChatMessage.self)
+ try context.delete(model: ArchiveMetadata.self)
+ try context.save()
+ }
+
+ private func fetchMetadata() -> ArchiveMetadata {
+ if let metadata { return metadata }
+ let existing = try? context.fetch(FetchDescriptor()).first
+ let metadata = existing ?? ArchiveMetadata(accountJID: accountJID)
+ context.insert(metadata)
+ self.metadata = metadata
+ return metadata
+ }
+
+ private func migrateLegacyJSONIfNeeded() {
+ guard FileManager.default.fileExists(atPath: legacyJSONURL.path) else { return }
+ guard let imported = try? LegacyArchiveImporter.importIfNeeded(from: legacyJSONURL) else {
+ return
+ }
+ for conversation in imported.conversations {
+ context.insert(conversation)
+ }
+ for message in imported.messages {
+ context.insert(message)
+ }
+ let metadata = fetchMetadata()
+ metadata.locallyDeletedMessageIDs = imported.locallyDeletedMessageIDs
+ metadata.rosterContactJIDs = imported.rosterContactJIDs
+ metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync
+ metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor
+ metadata.mamCheckpoints = imported.mamCheckpoints
+ do {
+ try context.save()
+ } catch {
+ // The imported rows stay pending in memory so this session keeps
+ // the history, but the legacy JSON remains on disk as the only
+ // durable copy until a save actually succeeds.
+ return
+ }
+ applyDataProtection()
+ try? FileManager.default.removeItem(at: legacyJSONURL)
+ }
+
+ /// Removes rows that older builds only marked as locally deleted (the
+ /// legacy snapshot filtered them at load time). `@Query`-based views read
+ /// the store directly, so these rows must physically disappear.
+ func purgeLocallyDeletedMessages() {
+ let metadata = fetchMetadata()
+ let deletedKeys = Set(metadata.locallyDeletedMessageIDs)
+ guard !deletedKeys.isEmpty else { return }
+ let messages = (try? context.fetch(FetchDescriptor())) ?? []
+ var removedAny = false
+ for message in messages {
+ let key = Self.deletionKey(
+ messageID: message.clientID,
+ conversationID: message.conversationID
+ )
+ guard deletedKeys.contains(key) else { continue }
+ context.delete(message)
+ removedAny = true
+ }
+ if removedAny {
+ try? context.save()
+ applyDataProtection()
+ }
+ }
+
+ /// Key format shared with `AppModel.localDeletionKey`:
+ /// `\u{1F}`.
+ static func deletionKey(messageID: String, conversationID: String) -> String {
+ conversationID.lowercased() + "\u{1F}" + messageID
+ }
+
+ private func applyDataProtection() {
+#if os(iOS)
+ try? FileManager.default.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
+ ofItemAtPath: storeURL.path
+ )
+#endif
+ }
+
+ static func stableHash(_ value: String) -> String {
+ let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
+ (partial ^ UInt64(byte)) &* 1_099_511_628_211
+ }
+ return String(hash, radix: 16, uppercase: false)
+ }
+}
diff --git a/Sources/Shared/Persistence/ChatArchive.swift b/Sources/Shared/Persistence/ChatArchive.swift
deleted file mode 100644
index 4ffc700..0000000
--- a/Sources/Shared/Persistence/ChatArchive.swift
+++ /dev/null
@@ -1,118 +0,0 @@
-import Foundation
-
-actor ChatArchive {
- struct Snapshot: Codable, Sendable {
- static let currentSchemaVersion = 5
-
- var schemaVersion: Int
- var conversations: [Conversation]
- var messages: [ChatMessage]
- var locallyDeletedMessageIDs: Set
- var rosterContactJIDs: Set
- var lastSuccessfulMAMSync: Date?
- var lastSuccessfulMAMCursor: String?
-
- init(
- conversations: [Conversation],
- messages: [ChatMessage],
- locallyDeletedMessageIDs: Set = [],
- rosterContactJIDs: Set = [],
- lastSuccessfulMAMSync: Date? = nil,
- lastSuccessfulMAMCursor: String? = nil
- ) {
- self.schemaVersion = Self.currentSchemaVersion
- self.conversations = conversations
- self.messages = messages
- self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
- self.rosterContactJIDs = rosterContactJIDs
- self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
- self.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
- lastSuccessfulMAMCursor
- )
- }
-
- private enum CodingKeys: String, CodingKey {
- case schemaVersion
- case conversations
- case messages
- case locallyDeletedMessageIDs
- case rosterContactJIDs
- case lastSuccessfulMAMSync
- case lastSuccessfulMAMCursor
- }
-
- init(from decoder: Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
- conversations = try values.decode([Conversation].self, forKey: .conversations)
- messages = try values.decode([ChatMessage].self, forKey: .messages)
- locallyDeletedMessageIDs = try values.decodeIfPresent(
- Set.self,
- forKey: .locallyDeletedMessageIDs
- ) ?? []
- rosterContactJIDs = try values.decodeIfPresent(
- Set.self,
- forKey: .rosterContactJIDs
- ) ?? []
- lastSuccessfulMAMSync = try values.decodeIfPresent(
- Date.self,
- forKey: .lastSuccessfulMAMSync
- )
- lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
- try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
- )
- }
-
- static let empty = Snapshot(conversations: [], messages: [])
- }
-
- private let fileURL: URL
- private let encoder: JSONEncoder
- private let decoder: JSONDecoder
-
- init(accountJID: String) {
- let fileManager = FileManager.default
- let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
- ?? fileManager.temporaryDirectory
- let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
- try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
- fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
-
- encoder = JSONEncoder()
- encoder.outputFormatting = [.sortedKeys]
- encoder.dateEncodingStrategy = .millisecondsSince1970
- decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .millisecondsSince1970
- }
-
- func load() -> Snapshot {
- guard let data = try? Data(contentsOf: fileURL),
- let snapshot = try? decoder.decode(Snapshot.self, from: data) else {
- return .empty
- }
- return snapshot
- }
-
- func save(_ snapshot: Snapshot) throws {
- let data = try encoder.encode(snapshot)
- try data.write(to: fileURL, options: [.atomic])
-#if os(iOS)
- try? FileManager.default.setAttributes(
- [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
- ofItemAtPath: fileURL.path
- )
-#endif
- }
-
- func erase() throws {
- guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
- try FileManager.default.removeItem(at: fileURL)
- }
-
- private static func stableHash(_ value: String) -> String {
- let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
- (partial ^ UInt64(byte)) &* 1_099_511_628_211
- }
- return String(hash, radix: 16, uppercase: false)
- }
-}
diff --git a/Sources/Shared/Persistence/LegacyArchiveImporter.swift b/Sources/Shared/Persistence/LegacyArchiveImporter.swift
new file mode 100644
index 0000000..bed14e1
--- /dev/null
+++ b/Sources/Shared/Persistence/LegacyArchiveImporter.swift
@@ -0,0 +1,417 @@
+import Foundation
+
+/// Reads the legacy JSON snapshot and converts it into SwiftData `@Model`
+/// objects once, so existing history survives the migration from the JSON file.
+/// Decoding mirrors the tolerant decoders of the removed `ChatArchive`:
+/// every field that was ever optional or added later falls back to a default,
+/// so snapshots written by older schema versions still import.
+enum LegacyArchiveImporter {
+ struct LegacyConversation: Codable {
+ enum Kind: String, Codable { case direct, group }
+ var id: String
+ var jid: String
+ var displayName: String
+ var lastMessage: String
+ var lastActivity: Date
+ var unreadCount: Int
+ var isOnline: Bool
+ var isPinned: Bool
+ var encryptionPreference: EncryptionPreference
+ var kind: Kind
+ var groupNickname: String?
+ var isGroupJoined: Bool
+ var shouldAutojoin: Bool
+ var occupantCount: Int
+ var invitedBy: String?
+
+ init(
+ id: String,
+ jid: String,
+ displayName: String,
+ lastMessage: String,
+ lastActivity: Date,
+ unreadCount: Int,
+ isOnline: Bool,
+ isPinned: Bool,
+ encryptionPreference: EncryptionPreference,
+ kind: Kind,
+ groupNickname: String? = nil,
+ isGroupJoined: Bool,
+ shouldAutojoin: Bool,
+ occupantCount: Int,
+ invitedBy: String? = nil
+ ) {
+ self.id = id
+ self.jid = jid
+ self.displayName = displayName
+ self.lastMessage = lastMessage
+ self.lastActivity = lastActivity
+ self.unreadCount = unreadCount
+ self.isOnline = isOnline
+ self.isPinned = isPinned
+ self.encryptionPreference = encryptionPreference
+ self.kind = kind
+ self.groupNickname = groupNickname
+ self.isGroupJoined = isGroupJoined
+ self.shouldAutojoin = shouldAutojoin
+ self.occupantCount = occupantCount
+ self.invitedBy = invitedBy
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case jid
+ case displayName
+ case lastMessage
+ case lastActivity
+ case unreadCount
+ case isOnline
+ case isPinned
+ case encryptionPreference
+ case kind
+ case groupNickname
+ case isGroupJoined
+ case shouldAutojoin
+ case occupantCount
+ case invitedBy
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ jid = try values.decode(String.self, forKey: .jid)
+ id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased()
+ displayName = try values.decode(String.self, forKey: .displayName)
+ lastMessage = try values.decode(String.self, forKey: .lastMessage)
+ lastActivity = try values.decode(Date.self, forKey: .lastActivity)
+ unreadCount = try values.decode(Int.self, forKey: .unreadCount)
+ isOnline = try values.decode(Bool.self, forKey: .isOnline)
+ isPinned = try values.decode(Bool.self, forKey: .isPinned)
+ encryptionPreference = try values.decodeIfPresent(
+ EncryptionPreference.self,
+ forKey: .encryptionPreference
+ ) ?? .inheritGlobal
+ kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct
+ groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname)
+ isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false
+ shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false
+ occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0)
+ invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy)
+ }
+ }
+
+ struct LegacyMessage: Codable {
+ enum Direction: String, Codable { case incoming, outgoing }
+ enum Delivery: String, Codable { case sending, sent, delivered, failed }
+ enum Security: String, Codable { case omemo, plaintext, decryptionFailed }
+ enum Kind: String, Codable {
+ case text, attachment, photo, video, audio, voice, videoNote, location, system
+ }
+ var id: String
+ var conversationID: String
+ var senderJID: String
+ var body: String
+ var timestamp: Date
+ var direction: Direction
+ var delivery: Delivery
+ var security: Security
+ var kind: Kind
+ var remoteAttachmentURL: String?
+ var localFilename: String?
+ var mimeType: String?
+ var duration: TimeInterval?
+ var byteCount: Int?
+ var encryptionFingerprint: String?
+ var editedAt: Date?
+ var replyToID: String?
+ var replyToJID: String?
+ var replyPreview: String?
+ var forwardedFrom: String?
+ var retractedAt: Date?
+ var originID: String?
+ var stanzaID: String?
+ var senderDisplayName: String?
+ var isGroupMessage: Bool
+ var callHistory: CallHistoryMetadata?
+ var reactions: [MessageReaction]
+
+ init(
+ id: String,
+ conversationID: String,
+ senderJID: String,
+ body: String,
+ timestamp: Date,
+ direction: Direction,
+ delivery: Delivery,
+ security: Security,
+ kind: Kind,
+ remoteAttachmentURL: String? = nil,
+ localFilename: String? = nil,
+ mimeType: String? = nil,
+ duration: TimeInterval? = nil,
+ byteCount: Int? = nil,
+ encryptionFingerprint: String? = nil,
+ editedAt: Date? = nil,
+ replyToID: String? = nil,
+ replyToJID: String? = nil,
+ replyPreview: String? = nil,
+ forwardedFrom: String? = nil,
+ retractedAt: Date? = nil,
+ originID: String? = nil,
+ stanzaID: String? = nil,
+ senderDisplayName: String? = nil,
+ isGroupMessage: Bool = false,
+ callHistory: CallHistoryMetadata? = nil,
+ reactions: [MessageReaction] = []
+ ) {
+ self.id = id
+ self.conversationID = conversationID
+ self.senderJID = senderJID
+ self.body = body
+ self.timestamp = timestamp
+ self.direction = direction
+ self.delivery = delivery
+ self.security = security
+ self.kind = kind
+ self.remoteAttachmentURL = remoteAttachmentURL
+ self.localFilename = localFilename
+ self.mimeType = mimeType
+ self.duration = duration
+ self.byteCount = byteCount
+ self.encryptionFingerprint = encryptionFingerprint
+ self.editedAt = editedAt
+ self.replyToID = replyToID
+ self.replyToJID = replyToJID
+ self.replyPreview = replyPreview
+ self.forwardedFrom = forwardedFrom
+ self.retractedAt = retractedAt
+ self.originID = originID
+ self.stanzaID = stanzaID
+ self.senderDisplayName = senderDisplayName
+ self.isGroupMessage = isGroupMessage
+ self.callHistory = callHistory
+ self.reactions = reactions
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case conversationID
+ case senderJID
+ case body
+ case timestamp
+ case direction
+ case delivery
+ case security
+ case kind
+ case remoteAttachmentURL
+ case localFilename
+ case mimeType
+ case duration
+ case byteCount
+ case encryptionFingerprint
+ case editedAt
+ case replyToID
+ case replyToJID
+ case replyPreview
+ case forwardedFrom
+ case retractedAt
+ case originID
+ case stanzaID
+ case senderDisplayName
+ case isGroupMessage
+ case callHistory
+ case reactions
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = try values.decode(String.self, forKey: .id)
+ conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
+ senderJID = try values.decode(String.self, forKey: .senderJID)
+ body = try values.decode(String.self, forKey: .body)
+ timestamp = try values.decode(Date.self, forKey: .timestamp)
+ direction = try values.decode(Direction.self, forKey: .direction)
+ delivery = try values.decode(Delivery.self, forKey: .delivery)
+ security = try values.decode(Security.self, forKey: .security)
+ kind = try values.decode(Kind.self, forKey: .kind)
+ remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
+ localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
+ mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
+ duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
+ byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
+ encryptionFingerprint = try values.decodeIfPresent(
+ String.self,
+ forKey: .encryptionFingerprint
+ )
+ editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
+ replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
+ replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID)
+ replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
+ forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
+ retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
+ originID = try values.decodeIfPresent(String.self, forKey: .originID)
+ stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
+ senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
+ isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
+ callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
+ reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
+ }
+ }
+
+ struct Snapshot: Codable {
+ static let currentSchemaVersion = 6
+
+ var schemaVersion: Int
+ var conversations: [LegacyConversation]
+ var messages: [LegacyMessage]
+ var locallyDeletedMessageIDs: Set
+ var rosterContactJIDs: Set
+ var lastSuccessfulMAMSync: Date?
+ var lastSuccessfulMAMCursor: String?
+ var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
+
+ init(
+ conversations: [LegacyConversation],
+ messages: [LegacyMessage],
+ locallyDeletedMessageIDs: Set = [],
+ rosterContactJIDs: Set = [],
+ lastSuccessfulMAMSync: Date? = nil,
+ lastSuccessfulMAMCursor: String? = nil,
+ mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
+ ) {
+ self.schemaVersion = Self.currentSchemaVersion
+ self.conversations = conversations
+ self.messages = messages
+ self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
+ self.rosterContactJIDs = rosterContactJIDs
+ self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
+ self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
+ self.mamCheckpoints = mamCheckpoints
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case schemaVersion
+ case conversations
+ case messages
+ case locallyDeletedMessageIDs
+ case rosterContactJIDs
+ case lastSuccessfulMAMSync
+ case lastSuccessfulMAMCursor
+ case mamCheckpoints
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
+ conversations = try values.decode([LegacyConversation].self, forKey: .conversations)
+ messages = try values.decode([LegacyMessage].self, forKey: .messages)
+ locallyDeletedMessageIDs = try values.decodeIfPresent(
+ Set.self,
+ forKey: .locallyDeletedMessageIDs
+ ) ?? []
+ rosterContactJIDs = try values.decodeIfPresent(
+ Set.self,
+ forKey: .rosterContactJIDs
+ ) ?? []
+ lastSuccessfulMAMSync = try values.decodeIfPresent(
+ Date.self,
+ forKey: .lastSuccessfulMAMSync
+ )
+ lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
+ try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
+ )
+ mamCheckpoints = try values.decodeIfPresent(
+ [MAMArchiveKey: MAMArchiveCheckpoint].self,
+ forKey: .mamCheckpoints
+ ) ?? [:]
+ }
+ }
+
+ struct Imported {
+ let conversations: [Conversation]
+ let messages: [ChatMessage]
+ let locallyDeletedMessageIDs: [String]
+ let rosterContactJIDs: [String]
+ let lastSuccessfulMAMSync: Date?
+ let lastSuccessfulMAMCursor: String?
+ let mamCheckpoints: [MAMCheckpointEntry]
+ }
+
+ static func importIfNeeded(from url: URL) throws -> Imported? {
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ let data = try Data(contentsOf: url)
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .millisecondsSince1970
+ let snapshot = try decoder.decode(Snapshot.self, from: data)
+
+ let conversations = snapshot.conversations.map { legacy -> Conversation in
+ let conversation = Conversation(
+ jid: legacy.jid,
+ displayName: legacy.displayName,
+ lastMessage: legacy.lastMessage,
+ lastActivity: legacy.lastActivity,
+ unreadCount: legacy.unreadCount,
+ isOnline: legacy.isOnline,
+ isPinned: legacy.isPinned,
+ encryptionPreference: legacy.encryptionPreference,
+ kind: legacy.kind == .group ? .group : .direct,
+ groupNickname: legacy.groupNickname,
+ isGroupJoined: legacy.isGroupJoined,
+ shouldAutojoin: legacy.shouldAutojoin,
+ occupantCount: legacy.occupantCount,
+ invitedBy: legacy.invitedBy
+ )
+ return conversation
+ }
+
+ let conversationByJID = Dictionary(
+ conversations.map { ($0.jid, $0) },
+ uniquingKeysWith: { first, _ in first }
+ )
+
+ let messages = snapshot.messages.map { legacy -> ChatMessage in
+ let message = ChatMessage(
+ id: legacy.id,
+ conversationID: legacy.conversationID,
+ senderJID: legacy.senderJID,
+ body: legacy.body,
+ timestamp: legacy.timestamp,
+ direction: legacy.direction == .outgoing ? .outgoing : .incoming,
+ delivery: ChatMessage.Delivery(rawValue: legacy.delivery.rawValue) ?? .sent,
+ security: ChatMessage.Security(rawValue: legacy.security.rawValue) ?? .plaintext,
+ kind: ChatMessage.Kind(rawValue: legacy.kind.rawValue) ?? .text,
+ remoteAttachmentURL: legacy.remoteAttachmentURL,
+ localFilename: legacy.localFilename,
+ mimeType: legacy.mimeType,
+ duration: legacy.duration,
+ byteCount: legacy.byteCount,
+ encryptionFingerprint: legacy.encryptionFingerprint,
+ editedAt: legacy.editedAt,
+ replyToID: legacy.replyToID,
+ replyToJID: legacy.replyToJID,
+ replyPreview: legacy.replyPreview,
+ forwardedFrom: legacy.forwardedFrom,
+ retractedAt: legacy.retractedAt,
+ originID: legacy.originID,
+ stanzaID: legacy.stanzaID,
+ senderDisplayName: legacy.senderDisplayName,
+ isGroupMessage: legacy.isGroupMessage,
+ callHistory: legacy.callHistory,
+ reactions: legacy.reactions
+ )
+ message.conversation = conversationByJID[legacy.conversationID.lowercased()]
+ return message
+ }
+
+ return Imported(
+ conversations: conversations,
+ messages: messages,
+ locallyDeletedMessageIDs: Array(snapshot.locallyDeletedMessageIDs),
+ rosterContactJIDs: Array(snapshot.rosterContactJIDs),
+ lastSuccessfulMAMSync: snapshot.lastSuccessfulMAMSync,
+ lastSuccessfulMAMCursor: snapshot.lastSuccessfulMAMCursor,
+ mamCheckpoints: snapshot.mamCheckpoints.map {
+ MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
+ }
+ )
+ }
+}
diff --git a/Sources/Shared/Security/AppLockVault.swift b/Sources/Shared/Security/AppLockVault.swift
new file mode 100644
index 0000000..3a7ad1f
--- /dev/null
+++ b/Sources/Shared/Security/AppLockVault.swift
@@ -0,0 +1,103 @@
+import Foundation
+import Security
+
+/// Stores the app-lock passcode in the Keychain and the lock preferences in
+/// UserDefaults. The lock is a UI-level gate: the passcode is kept only in
+/// the Keychain and is never mirrored into memory beyond verification.
+final class AppLockVault {
+ private let service = "app.luma.chat.applock"
+ private let account = "applock-passcode"
+ private let enabledKey = "appLockEnabled"
+ private let biometricKey = "appLockBiometricUnlock"
+ private let defaults: UserDefaults
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ }
+
+ var isEnabled: Bool {
+ get { defaults.bool(forKey: enabledKey) }
+ set { defaults.set(newValue, forKey: enabledKey) }
+ }
+
+ var biometricUnlockEnabled: Bool {
+ get { defaults.bool(forKey: biometricKey) }
+ set { defaults.set(newValue, forKey: biometricKey) }
+ }
+
+ func save(passcode: String) throws {
+ let data = Data(passcode.utf8)
+ let baseQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ ]
+ let updateStatus = SecItemUpdate(
+ baseQuery as CFDictionary,
+ [kSecValueData: data] as CFDictionary
+ )
+ if updateStatus == errSecSuccess {
+ return
+ }
+ guard updateStatus == errSecItemNotFound else {
+ throw AppLockVaultError.unhandled(updateStatus)
+ }
+ var insertion = baseQuery
+ insertion[kSecValueData] = data
+#if !os(macOS)
+ insertion[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+#endif
+ let addStatus = SecItemAdd(insertion as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw AppLockVaultError.unhandled(addStatus)
+ }
+ }
+
+ func deletePasscode() throws {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ ]
+ let status = SecItemDelete(query as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw AppLockVaultError.unhandled(status)
+ }
+ }
+
+ func verify(passcode: String) -> Bool {
+ guard let stored = storedPasscode() else { return false }
+ return passcode == stored
+ }
+
+ private func storedPasscode() -> String? {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne,
+ ]
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+ guard status == errSecSuccess,
+ let data = item as? Data,
+ let value = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+ return value
+ }
+}
+
+enum AppLockVaultError: LocalizedError {
+ case unhandled(OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .unhandled(let status):
+ let message = SecCopyErrorMessageString(status, nil) as String?
+ return message ?? "Не удалось обратиться к Keychain (\(status))."
+ }
+ }
+}
+
diff --git a/Sources/Shared/Services/ChatMediaImageCache.swift b/Sources/Shared/Services/ChatMediaImageCache.swift
index e1b9b0e..eaac45f 100644
--- a/Sources/Shared/Services/ChatMediaImageCache.swift
+++ b/Sources/Shared/Services/ChatMediaImageCache.swift
@@ -24,7 +24,7 @@ enum ChatMediaImageCache {
for message: ChatMessage,
data: Data
) -> ChatMediaPlatformImage? {
- let cacheKey = "\(message.conversationID)\u{1F}\(message.id)" as NSString
+ let cacheKey = "\(message.conversationID)\u{1F}\(message.clientID)" as NSString
if let cached = cache.object(forKey: cacheKey) {
return cached
}
diff --git a/Sources/Shared/Services/MediaDownloadService.swift b/Sources/Shared/Services/MediaDownloadService.swift
new file mode 100644
index 0000000..e85eebe
--- /dev/null
+++ b/Sources/Shared/Services/MediaDownloadService.swift
@@ -0,0 +1,247 @@
+import Foundation
+import UniformTypeIdentifiers
+
+#if os(iOS)
+import Photos
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+/// Saves a message's cached media to the right destination: photos, videos and
+/// video notes go to the Photos library on iOS (or Downloads on macOS, which
+/// would otherwise need a Photo Library entitlement), while files, audio and
+/// voice messages go to the user's Downloads location. Kept dependency-free so
+/// every bubble, the full-screen viewer and the audio player share one path.
+@MainActor
+enum MediaDownloadService {
+ enum SaveError: LocalizedError {
+ case missingLocalFile
+ case unsupportedKind
+
+ var errorDescription: String? {
+ switch self {
+ case .missingLocalFile:
+ return "Не удалось загрузить файл для сохранения."
+ case .unsupportedKind:
+ return "Этот тип сообщения нельзя сохранить."
+ }
+ }
+ }
+
+ // MARK: - Entry points
+
+ /// Resolve the cached copy for `message` (downloading through AppModel when
+ /// needed) and save it. Failures are surfaced through AppModel's error alert.
+ static func save(_ message: ChatMessage, model: AppModel) async {
+ do {
+ guard let sourceURL = await resolvedLocalURL(for: message, model: model) else {
+ throw SaveError.missingLocalFile
+ }
+ let filename = message.localFilename ?? Self.defaultFilename(for: message)
+ let didSave = try await save(
+ url: sourceURL,
+ kind: message.kind,
+ filename: filename
+ )
+ if didSave {
+ model.informationalMessage = "Сохранено."
+ }
+ } catch {
+ model.errorMessage = error.localizedDescription
+ }
+ }
+
+ /// Save an already-downloaded file, routing by kind and platform. Returns
+ /// `false` when the user cancelled an interactive save sheet.
+ @discardableResult
+ static func save(
+ url: URL,
+ kind: ChatMessage.Kind,
+ filename: String
+ ) async throws -> Bool {
+ switch kind {
+ case .photo, .video, .videoNote:
+ #if os(iOS)
+ try await saveToPhotos(url: url, kind: kind)
+ return true
+ #else
+ return try await saveToDownloads(url: url, filename: filename)
+ #endif
+ case .attachment, .audio, .voice:
+ return try await saveToDownloads(url: url, filename: filename)
+ case .text, .location, .system:
+ throw SaveError.unsupportedKind
+ }
+ }
+
+ // MARK: - Local file resolution
+
+ private static func resolvedLocalURL(
+ for message: ChatMessage,
+ model: AppModel
+ ) async -> URL? {
+ if let cached = model.mediaPreviewURL(for: message) {
+ return cached
+ }
+ // Inline media (photo/video/audio/voice/video note) is fetched by
+ // AppModel into its preview cache; files are not auto-previewed and fall
+ // through to the QuickLook handoff below.
+ await model.prepareMediaPreview(message)
+ if let cached = model.mediaPreviewURL(for: message) {
+ return cached
+ }
+ // Attachments are only fetched on demand via previewAttachment(_:),
+ // which parks the result in the single-shot previewURL property. Clear it
+ // before and after so the QuickLook sheet does not flash open.
+ model.previewURL = nil
+ await model.previewAttachment(message)
+ let url = model.previewURL
+ model.previewURL = nil
+ return url
+ }
+
+ // MARK: - Photos
+
+ #if os(iOS)
+ private static func saveToPhotos(url: URL, kind: ChatMessage.Kind) async throws {
+ try await PHPhotoLibrary.shared().performChanges {
+ if kind == .photo {
+ _ = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url)
+ } else {
+ _ = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
+ }
+ }
+ }
+ #endif
+
+ // MARK: - Downloads
+
+ private static func saveToDownloads(url: URL, filename: String) async throws -> Bool {
+ #if os(macOS)
+ return try await saveWithPanel(url: url, filename: filename)
+ #else
+ return await FileExportPresenter.shared.present(url: url)
+ #endif
+ }
+
+ #if os(macOS)
+ /// Present a save panel defaulting to ~/Downloads. The app sandbox grants
+ /// write access to the location the user picks, so no Downloads entitlement
+ /// is required.
+ private static func saveWithPanel(url: URL, filename: String) async throws -> Bool {
+ let panel = NSSavePanel()
+ panel.nameFieldStringValue = filename
+ panel.canCreateDirectories = true
+ if let downloads = FileManager.default.urls(
+ for: .downloadsDirectory,
+ in: .userDomainMask
+ ).first {
+ panel.directoryURL = downloads
+ }
+ guard panel.runModal() == .OK, let destination = panel.url else {
+ return false
+ }
+ try FileManager.default.copyItem(at: url, to: destination)
+ return true
+ }
+ #endif
+
+ // MARK: - Helpers
+
+ private static func defaultFilename(for message: ChatMessage) -> String {
+ let ext = Self.fileExtension(for: message)
+ let base: String
+ switch message.kind {
+ case .photo: base = "Фото"
+ case .video: base = "Видео"
+ case .videoNote: base = "Видеосообщение"
+ case .voice: base = "Голосовое сообщение"
+ case .audio: base = "Аудио"
+ case .attachment: base = "Вложение"
+ default: base = "Файл"
+ }
+ return ext.isEmpty ? base : "\(base).\(ext)"
+ }
+
+ private static func fileExtension(for message: ChatMessage) -> String {
+ if let filename = message.localFilename {
+ let ext = (filename as NSString).pathExtension
+ if !ext.isEmpty { return ext }
+ }
+ if let mime = message.mimeType,
+ let type = UTType(mimeType: mime),
+ let ext = type.preferredFilenameExtension
+ {
+ return ext
+ }
+ switch message.kind {
+ case .video, .videoNote: return "mp4"
+ case .voice, .audio: return "m4a"
+ case .photo: return "jpg"
+ default: return ""
+ }
+ }
+}
+
+#if os(iOS)
+/// Presents the system "save to Files" sheet for a cached file and reports
+/// whether the user completed the save or cancelled. Held alive by the
+/// continuation until the delegate fires.
+@MainActor
+private final class FileExportPresenter: NSObject, UIDocumentPickerDelegate {
+ static let shared = FileExportPresenter()
+
+ private var continuation: CheckedContinuation?
+
+ func present(url: URL) async -> Bool {
+ await withCheckedContinuation { continuation in
+ self.continuation = continuation
+ let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
+ picker.delegate = self
+ Task { @MainActor in
+ // Let a just-dismissed context menu finish its transition before
+ // presenting, otherwise the sheet can be rejected.
+ try? await Task.sleep(nanoseconds: 350_000_000)
+ guard let presenter = Self.topPresenter(),
+ presenter.presentedViewController == nil
+ else {
+ self.finish(false)
+ return
+ }
+ presenter.present(picker, animated: true)
+ }
+ }
+ }
+
+ func documentPicker(
+ _ controller: UIDocumentPickerViewController,
+ didPickDocumentsAt urls: [URL]
+ ) {
+ finish(true)
+ }
+
+ func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
+ finish(false)
+ }
+
+ private func finish(_ result: Bool) {
+ continuation?.resume(returning: result)
+ continuation = nil
+ }
+
+ private static func topPresenter() -> UIViewController? {
+ guard let scene = UIApplication.shared.connectedScenes
+ .compactMap({ $0 as? UIWindowScene })
+ .first(where: { $0.activationState == .foregroundActive }),
+ let window = scene.windows.first(where: { $0.isKeyWindow }),
+ let root = window.rootViewController
+ else { return nil }
+ var top = root
+ while let presented = top.presentedViewController {
+ top = presented
+ }
+ return top
+ }
+}
+#endif
diff --git a/Sources/Shared/Services/MediaPreviewProcessor.swift b/Sources/Shared/Services/MediaPreviewProcessor.swift
index 9a004ec..c59d7c2 100644
--- a/Sources/Shared/Services/MediaPreviewProcessor.swift
+++ b/Sources/Shared/Services/MediaPreviewProcessor.swift
@@ -1,6 +1,7 @@
import AVFoundation
import Foundation
import ImageIO
+import os
import UniformTypeIdentifiers
actor MediaPreviewProcessor {
@@ -44,6 +45,12 @@ actor MediaPreviewProcessor {
}
let waveform = kind == .voice ? voiceWaveform(for: url) : nil
+ if kind == .video {
+ let logger = Logger(subsystem: "Luma", category: "video-preview")
+ logger.info(
+ "analyze video: \(url.lastPathComponent) duration=\(String(describing: duration)) thumbnailBytes=\(thumbnailData?.count ?? 0)"
+ )
+ }
return Analysis(
duration: duration,
thumbnailData: thumbnailData,
@@ -94,6 +101,8 @@ actor MediaPreviewProcessor {
return data
}
} catch {
+ Logger(subsystem: "Luma", category: "video-preview")
+ .error("image generator failed at \(second)s: \(error.localizedDescription)")
continue
}
}
diff --git a/Sources/Shared/Services/PhoneWatchBridge.swift b/Sources/Shared/Services/PhoneWatchBridge.swift
index 523e1b8..a23c26b 100644
--- a/Sources/Shared/Services/PhoneWatchBridge.swift
+++ b/Sources/Shared/Services/PhoneWatchBridge.swift
@@ -19,6 +19,24 @@ struct PhoneWatchSnapshot: Codable {
let chats: [Chat]
}
+/// Plain-value snapshots of the SwiftData models, taken on the main actor
+/// before the background queue builds the watch payload. Reading @Model
+/// properties off the main actor crashes with EXC_BAD_ACCESS.
+private struct PhoneWatchChatData {
+ let jid: String
+ let name: String
+ let unread: Int
+}
+
+private struct PhoneWatchMessageData {
+ let id: String
+ let conversationID: String
+ let body: String
+ let timestamp: Date
+ let outgoing: Bool
+ let encrypted: Bool
+}
+
struct WatchVoiceMessage: Sendable {
let transferID: String
let jid: String
@@ -32,9 +50,9 @@ struct WatchVoiceMessage: Sendable {
}
#if os(iOS) && canImport(WatchConnectivity)
-import WatchConnectivity
+@preconcurrency import WatchConnectivity
-final class PhoneWatchBridge: NSObject, WCSessionDelegate {
+final class PhoneWatchBridge: NSObject, WCSessionDelegate, @unchecked Sendable {
var onReply: ((String, String) -> Void)?
var onVoiceMessage: ((WatchVoiceMessage) -> Void)? {
didSet { deliverPendingVoiceMessages() }
@@ -49,25 +67,43 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
private var deliveredVoiceTransferIDs: Set = []
override init() {
- session = WCSession.isSupported() ? .default : nil
+ session =
+ !RuntimeEnvironment.isRunningTests && WCSession.isSupported()
+ ? .default
+ : nil
super.init()
pendingVoiceMessages = Self.loadPersistedVoiceMessages()
session?.delegate = self
session?.activate()
}
+ @MainActor
func update(conversations: [Conversation], messages: [ChatMessage]) {
guard let session else { return }
- // Copy-on-write snapshots are cheap here; the filtering, sorting and
- // JSON encoding are not. Keep that work away from SwiftUI's main run
- // loop, especially immediately after a MAM page is committed.
+ // SwiftData models must never be read off the main actor: snapshot
+ // the fields the watch needs here, then hand plain values to the
+ // background queue for the filtering, sorting and JSON encoding.
+ let chats = conversations.map { conversation in
+ PhoneWatchChatData(
+ jid: conversation.jid,
+ name: conversation.displayName,
+ unread: conversation.unreadCount
+ )
+ }
+ let entries = messages.map { message in
+ PhoneWatchMessageData(
+ id: message.clientID,
+ conversationID: message.conversationID,
+ body: message.previewText,
+ timestamp: message.timestamp,
+ outgoing: message.direction == .outgoing,
+ encrypted: message.security == .omemo
+ )
+ }
snapshotQueue.async { [weak self, weak session] in
guard let self,
let session,
- let data = Self.makeSnapshotData(
- conversations: conversations,
- messages: messages
- ),
+ let data = Self.makeSnapshotData(chats: chats, entries: entries),
data != self.latestSnapshot else { return }
self.latestSnapshot = data
self.publishLatestSnapshot(using: session)
@@ -75,57 +111,56 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
}
private static func makeSnapshotData(
- conversations: [Conversation],
- messages: [ChatMessage]
+ chats: [PhoneWatchChatData],
+ entries: [PhoneWatchMessageData]
) -> Data? {
- let visibleConversations = Array(conversations.prefix(20))
- let visibleConversationIDs = Set(visibleConversations.map(\.id))
- var recentMessagesByConversation: [String: [ChatMessage]] = [:]
+ let visibleChats = Array(chats.prefix(20))
+ let visibleChatIDs = Set(visibleChats.map(\.jid))
+ var recentMessagesByChat: [String: [PhoneWatchMessageData]] = [:]
// Keep a bounded, chronologically sorted window. MAM can append an old
// overlapping stanza after a newer live message, so array order alone
// cannot identify the latest messages reliably.
- for message in messages where visibleConversationIDs.contains(message.conversationID) {
- var recent = recentMessagesByConversation[message.conversationID] ?? []
- let insertionIndex = Self.insertionIndex(for: message, in: recent)
- recent.insert(message, at: insertionIndex)
+ for entry in entries where visibleChatIDs.contains(entry.conversationID) {
+ var recent = recentMessagesByChat[entry.conversationID] ?? []
+ let insertionIndex = Self.insertionIndex(for: entry, in: recent)
+ recent.insert(entry, at: insertionIndex)
if recent.count > 20 {
recent.removeFirst(recent.count - 20)
}
- recentMessagesByConversation[message.conversationID] = recent
+ recentMessagesByChat[entry.conversationID] = recent
}
- let chats = visibleConversations.map { conversation in
+ let snapshotChats = visibleChats.map { chat in
PhoneWatchSnapshot.Chat(
- jid: conversation.jid,
- name: conversation.displayName,
- unread: conversation.unreadCount,
- messages: (recentMessagesByConversation[conversation.id] ?? [])
- .map {
- PhoneWatchSnapshot.Message(
- id: $0.id,
- body: $0.previewText,
- timestamp: $0.timestamp,
- outgoing: $0.direction == .outgoing,
- encrypted: $0.security == .omemo
- )
- }
+ jid: chat.jid,
+ name: chat.name,
+ unread: chat.unread,
+ messages: (recentMessagesByChat[chat.jid] ?? []).map { entry in
+ PhoneWatchSnapshot.Message(
+ id: entry.id,
+ body: entry.body,
+ timestamp: entry.timestamp,
+ outgoing: entry.outgoing,
+ encrypted: entry.encrypted
+ )
+ }
)
}
- return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: chats))
+ return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: snapshotChats))
}
private static func insertionIndex(
- for message: ChatMessage,
- in sortedMessages: [ChatMessage]
+ for entry: PhoneWatchMessageData,
+ in sorted: [PhoneWatchMessageData]
) -> Int {
var lowerBound = 0
- var upperBound = sortedMessages.count
+ var upperBound = sorted.count
while lowerBound < upperBound {
let middle = lowerBound + (upperBound - lowerBound) / 2
- let existing = sortedMessages[middle]
- let existingComesFirst = existing.timestamp < message.timestamp
- || (existing.timestamp == message.timestamp && existing.id < message.id)
+ let existing = sorted[middle]
+ let existingComesFirst = existing.timestamp < entry.timestamp
+ || (existing.timestamp == entry.timestamp && existing.id < entry.id)
if existingComesFirst {
lowerBound = middle + 1
} else {
@@ -150,6 +185,7 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
+ guard !RuntimeEnvironment.isRunningTests else { return }
session.activate()
}
diff --git a/Sources/Shared/Services/VideoNoteRecorder.swift b/Sources/Shared/Services/VideoNoteRecorder.swift
index aa96fb1..777159f 100644
--- a/Sources/Shared/Services/VideoNoteRecorder.swift
+++ b/Sources/Shared/Services/VideoNoteRecorder.swift
@@ -1,9 +1,16 @@
@preconcurrency import AVFoundation
import Combine
+import CoreImage
import Foundation
+import os
+
+private let videoNoteFinalizeLogger = Logger(
+ subsystem: "Luma",
+ category: "video-note-finalize"
+)
#if os(iOS)
-import UIKit
+ import UIKit
#endif
@MainActor
@@ -17,6 +24,7 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
@Published private(set) var isRecording = false
@Published private(set) var elapsed: TimeInterval = 0
@Published private(set) var isUsingFrontCamera = true
+ @Published private(set) var hasAlternateCamera = false
@Published private(set) var isMicrophoneMuted = false
let session = AVCaptureSession()
@@ -47,6 +55,16 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
private var startupTimeoutTask: Task?
private var finalizationTimeoutTask: Task?
private var fileValidationTask: Task?
+ private var cameraSwitchContinuation: CheckedContinuation?
+ private var cameraFlipTimeoutTask: Task?
+ private var restoreAudioRouteTask: Task?
+ private var cameraFlipRequested = false
+ private var restartingSegmentForCameraFlip = false
+ private var recordedSegments: [URL] = []
+ private var segmentsTotalDuration: TimeInterval = 0
+ /// The iOS front camera routes the quiet earpiece microphone, so the
+ /// finalized audio gets a gain boost when any segment used it.
+ private var usedFrontCamera = false
private struct CaptureGraph {
let cameraInput: AVCaptureDeviceInput
@@ -92,14 +110,18 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
}
}
-#if os(iOS)
- try AVAudioSession.sharedInstance().setCategory(
- .playAndRecord,
- mode: .videoRecording,
- options: [.defaultToSpeaker, .allowBluetoothHFP]
- )
- try AVAudioSession.sharedInstance().setActive(true)
-#endif
+ #if os(iOS)
+ try AVAudioSession.sharedInstance().setCategory(
+ .playAndRecord,
+ // .default keeps voice processing and automatic gain control:
+ // a talking circle recorded at arm's length (front camera)
+ // stays loud and consistent, unlike .videoRecording which
+ // disables AGC and records the raw microphone level.
+ mode: .default,
+ options: [.defaultToSpeaker, .allowBluetoothHFP]
+ )
+ try AVAudioSession.sharedInstance().setActive(true)
+ #endif
let graph = try await configureCaptureGraph()
guard !Task.isCancelled, sessionGeneration == generation else {
@@ -108,6 +130,8 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
cameraInput = graph.cameraInput
microphoneInput = graph.microphoneInput
isUsingFrontCamera = graph.usesFrontCamera
+ hasAlternateCamera =
+ hasCamera(at: graph.usesFrontCamera ? .back : .front)
configureVideoConnection()
try await startCaptureSession(generation: generation)
@@ -118,10 +142,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
func start() throws {
guard lifecycle == .prepared,
- isPrepared,
- session.isRunning else { throw VideoNoteRecorderError.notPrepared }
+ isPrepared,
+ session.isRunning
+ else { throw VideoNoteRecorderError.notPrepared }
guard !movieOutput.isRecording,
- activeRecordingURL == nil else {
+ activeRecordingURL == nil
+ else {
throw VideoNoteRecorderError.recordingBusy
}
@@ -135,8 +161,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
requestedMinimumDuration = 0
activeRecordingURL = url
lifecycle = .starting
- startedAt = nil
- elapsed = 0
+ if !restartingSegmentForCameraFlip {
+ startedAt = nil
+ elapsed = 0
+ }
+ restartingSegmentForCameraFlip = false
+ if isUsingFrontCamera {
+ usedFrontCamera = true
+ }
isRecording = true
let output = movieOutput
@@ -168,8 +200,9 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
guard let self else { return }
await self.stopMovieOutput()
guard !Task.isCancelled,
- self.lifecycle == .stopping,
- let url = self.activeRecordingURL else { return }
+ self.lifecycle == .stopping,
+ let url = self.activeRecordingURL
+ else { return }
self.scheduleFinalizationTimeout(for: url)
}
case .stopping:
@@ -179,19 +212,120 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
}
}
+ /// Flips between the front and the back camera. While recording,
+ /// AVFoundation tears the active movie down when its input is removed, so
+ /// the current segment is closed first, the camera is swapped, and a new
+ /// segment starts; the segments are merged back together on finalization.
func switchCamera() async throws {
-#if os(iOS)
- guard isPrepared,
- lifecycle == .prepared,
- let currentInput = cameraInput else {
- throw VideoNoteRecorderError.notPrepared
+ #if os(iOS)
+ guard isPrepared,
+ hasAlternateCamera,
+ let currentInput = cameraInput
+ else {
+ throw VideoNoteRecorderError.notPrepared
+ }
+ switch lifecycle {
+ case .prepared:
+ try await swapCameraInput(replacing: currentInput)
+ case .starting, .recording:
+ try await stopSegmentForCameraFlip()
+ guard let input = cameraInput else {
+ throw VideoNoteRecorderError.notPrepared
+ }
+ try await swapCameraInput(replacing: input)
+ restartingSegmentForCameraFlip = true
+ try start()
+ case .stopping, .idle:
+ throw VideoNoteRecorderError.notPrepared
+ }
+ #else
+ throw VideoNoteRecorderError.alternateCameraUnavailable
+ #endif
+ }
+
+ /// Closes the in-flight segment without finishing the whole recording:
+ /// the file is kept (when non-empty), per-segment state resets, and the
+ /// pending switchCamera call resumes.
+ private func stopSegmentForCameraFlip() async throws {
+ try await withCheckedThrowingContinuation {
+ (continuation: CheckedContinuation) in
+ cameraSwitchContinuation = continuation
+ cameraFlipRequested = true
+ requestStop(afterMinimumDuration: 0)
+ scheduleCameraFlipTimeout()
}
- let nextPosition: AVCaptureDevice.Position = isUsingFrontCamera ? .back : .front
- guard let camera = AVCaptureDevice.default(
- .builtInWideAngleCamera,
- for: .video,
- position: nextPosition
- ) else {
+ }
+
+ private func scheduleCameraFlipTimeout() {
+ cameraFlipTimeoutTask?.cancel()
+ cameraFlipTimeoutTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: 8_000_000_000)
+ guard !Task.isCancelled,
+ let self,
+ let continuation = self.cameraSwitchContinuation
+ else { return }
+ self.cameraSwitchContinuation = nil
+ self.cameraFlipRequested = false
+ continuation.resume(
+ throwing: VideoNoteRecorderError.finalizationTimedOut
+ )
+ }
+ }
+
+ private func handleCameraFlipSegment(url: URL) {
+ cameraFlipTimeoutTask?.cancel()
+ cameraFlipTimeoutTask = nil
+ let continuation = cameraSwitchContinuation
+ cameraSwitchContinuation = nil
+
+ fileValidationTask?.cancel()
+ fileValidationTask = Task { @MainActor [weak self] in
+ guard let self else { return }
+ let inspection = await self.fileInspector.inspect(
+ url: url,
+ fallbackDuration: self.measuredWallClockDuration
+ )
+ guard !Task.isCancelled,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
+ activeURL: self.activeRecordingURL,
+ outputURL: url
+ )
+ else { return }
+ self.fileValidationTask = nil
+ if let inspection, inspection.duration > 0.05 {
+ self.recordedSegments.append(url)
+ self.segmentsTotalDuration += inspection.duration
+ } else {
+ try? FileManager.default.removeItem(at: url)
+ }
+ self.resetForNextSegment()
+ continuation?.resume(returning: ())
+ }
+ }
+
+ private func resetForNextSegment() {
+ lifecycle = .prepared
+ activeRecordingURL = nil
+ discardCurrentRecording = false
+ stopRequested = false
+ requestedMinimumDuration = 0
+ isRecording = false
+ timer?.invalidate()
+ timer = nil
+ }
+
+ private func swapCameraInput(
+ replacing currentInput: AVCaptureDeviceInput
+ ) async throws {
+ let nextPosition: AVCaptureDevice.Position =
+ isUsingFrontCamera ? .back : .front
+ guard
+ let camera = AVCaptureDevice.default(
+ .builtInWideAngleCamera,
+ for: .video,
+ position: nextPosition
+ )
+ else {
throw VideoNoteRecorderError.alternateCameraUnavailable
}
@@ -207,7 +341,9 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
session.addInput(currentInput)
}
session.commitConfiguration()
- continuation.resume(throwing: VideoNoteRecorderError.configurationFailed)
+ continuation.resume(
+ throwing: VideoNoteRecorderError.configurationFailed
+ )
return
}
session.addInput(nextInput)
@@ -218,10 +354,30 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
cameraInput = nextInput
isUsingFrontCamera = nextPosition == .front
+ hasAlternateCamera =
+ hasCamera(at: nextPosition == .front ? .back : .front)
configureVideoConnection()
-#else
- throw VideoNoteRecorderError.alternateCameraUnavailable
-#endif
+ #if os(iOS)
+ // Flipping to the front camera makes iOS re-route the audio to
+ // the earpiece and switch the voice processing into the quiet
+ // receiver mode — but the system performs that re-routing
+ // asynchronously AFTER the swap commits. Restore the speaker
+ // route with a short delay so it wins the race.
+ restoreAudioRouteTask?.cancel()
+ restoreAudioRouteTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: 800_000_000)
+ guard !Task.isCancelled, let self, self.isPrepared else {
+ return
+ }
+ let audioSession = AVAudioSession.sharedInstance()
+ try? audioSession.setCategory(
+ .playAndRecord,
+ mode: .default,
+ options: [.defaultToSpeaker, .allowBluetoothHFP]
+ )
+ try? audioSession.overrideOutputAudioPort(.speaker)
+ }
+ #endif
}
func toggleMicrophoneMuted() {
@@ -255,13 +411,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
error: Error?
) {
Task { @MainActor [weak self] in
- self?.recordingDidFinish(url: outputFileURL, error: error)
+ await self?.recordingDidFinish(url: outputFileURL, error: error)
}
}
private func requestStop(afterMinimumDuration minimumDuration: TimeInterval) {
guard isRecording,
- lifecycle == .starting || lifecycle == .recording else { return }
+ lifecycle == .starting || lifecycle == .recording
+ else { return }
stopRequested = true
requestedMinimumDuration = max(
requestedMinimumDuration,
@@ -271,11 +428,13 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
}
private func recordingDidStart(url: URL) {
- guard VideoNoteRecordingLifecycle.acceptsCompletion(
- activeURL: activeRecordingURL,
- outputURL: url
- ), isRecording,
- lifecycle == .starting || lifecycle == .stopping else { return }
+ guard
+ VideoNoteRecordingLifecycle.acceptsCompletion(
+ activeURL: activeRecordingURL,
+ outputURL: url
+ ), isRecording,
+ lifecycle == .starting || lifecycle == .stopping
+ else { return }
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
@@ -293,11 +452,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
guard let self else { return }
await self.stopMovieOutput()
guard !Task.isCancelled,
- self.lifecycle == .stopping,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ self.lifecycle == .stopping,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
self.scheduleFinalizationTimeout(for: url)
}
return
@@ -319,9 +479,10 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
private func scheduleStopAfterActualStartIfNeeded() {
guard stopRequested,
- lifecycle == .recording,
- startedAt != nil,
- let url = activeRecordingURL else { return }
+ lifecycle == .recording,
+ startedAt != nil,
+ let url = activeRecordingURL
+ else { return }
stopRequestTask?.cancel()
let minimumDuration = requestedMinimumDuration
@@ -331,20 +492,22 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
stopRequestTask = Task { @MainActor [weak self] in
while true {
guard !Task.isCancelled,
- let self,
- self.lifecycle == .recording,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ let self,
+ self.lifecycle == .recording,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
let snapshot = await self.movieOutputSnapshot()
guard !Task.isCancelled,
- self.lifecycle == .recording,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ self.lifecycle == .recording,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
if !snapshot.isRecording {
// AVFoundation stopped without the user's stop reaching
@@ -356,7 +519,8 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
}
let remaining = VideoNoteStopPolicy.remainingRecordedDuration(
- recordedDuration: snapshot.recordedDuration,
+ recordedDuration: snapshot.recordedDuration
+ + self.segmentsTotalDuration,
minimumDuration: minimumDuration
)
if remaining <= 0 || Date() >= mediaDeadline { break }
@@ -374,30 +538,34 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
}
guard !Task.isCancelled,
- let self,
- self.lifecycle == .recording,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ let self,
+ self.lifecycle == .recording,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
self.lifecycle = .stopping
await self.stopMovieOutput()
guard !Task.isCancelled,
- self.lifecycle == .stopping,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ self.lifecycle == .stopping,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
self.scheduleFinalizationTimeout(for: url)
}
}
- private func recordingDidFinish(url: URL, error: Error?) {
- guard VideoNoteRecordingLifecycle.acceptsCompletion(
- activeURL: activeRecordingURL,
- outputURL: url
- ) else {
+ private func recordingDidFinish(url: URL, error: Error?) async {
+ guard
+ VideoNoteRecordingLifecycle.acceptsCompletion(
+ activeURL: activeRecordingURL,
+ outputURL: url
+ )
+ else {
// A delayed callback from an earlier attempt must not tear down a
// newer capture graph. Do not delete it here: macOS can spell the
// same temporary URL through a filesystem alias, and preserving a
@@ -416,6 +584,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
isRecording = false
lifecycle = .stopping
+ if cameraFlipRequested {
+ // A flip during recording first closes the current segment: keep
+ // it and hand control back to the pending switchCamera call.
+ cameraFlipRequested = false
+ handleCameraFlipSegment(url: url)
+ return
+ }
+
let fallbackDuration = measuredWallClockDuration
let shouldDiscard = discardCurrentRecording
let wasStopRequested = stopRequested
@@ -425,6 +601,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
return
}
+ // Release the camera BEFORE the finalize re-encode runs. While the
+ // capture session is still live, the writer's encoder contends with
+ // it for the media hardware and its input can block forever in
+ // isReadyForMoreMediaData — the «Завершение…» hang.
+ await flushCaptureSessionForFinalization()
+
fileValidationTask?.cancel()
fileValidationTask = Task { @MainActor [weak self] in
guard let self else { return }
@@ -433,10 +615,11 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
fallbackDuration: fallbackDuration
)
guard !Task.isCancelled,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
self.fileValidationTask = nil
if self.discardCurrentRecording {
@@ -446,29 +629,63 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
}
if let inspection,
- VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
- stopRequested: wasStopRequested,
- error: error,
- recordedDuration: inspection.duration,
- maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
- ),
- VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) {
- self.finishAndReset(.success(Recording(
- url: url,
- duration: inspection.duration
- )))
+ VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
+ stopRequested: wasStopRequested,
+ error: error,
+ recordedDuration: self.segmentsTotalDuration
+ + inspection.duration,
+ maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
+ ),
+ VideoNoteStopPolicy.isValidFinalDuration(
+ self.segmentsTotalDuration + inspection.duration
+ )
+ {
+ let totalDuration =
+ self.segmentsTotalDuration + inspection.duration
+ // Merge the flip segments back together (passthrough, no
+ // re-encode). The circle bubble crops the picture visually.
+ let finalization = await self.fileInspector.finalizeVideoNote(
+ segments: self.recordedSegments + [url],
+ frontCameraUsed: self.usedFrontCamera
+ )
+ guard !Task.isCancelled,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
+ activeURL: self.activeRecordingURL,
+ outputURL: url
+ )
+ else { return }
+ if let finalization {
+ if finalization.url != url {
+ try? FileManager.default.removeItem(at: url)
+ }
+ self.finishAndReset(
+ .success(
+ Recording(
+ url: finalization.url,
+ duration: finalization.duration
+ )))
+ } else {
+ // The transcode fell through; the raw file was already
+ // validated, so send it uncropped instead of losing the
+ // recording entirely.
+ self.finishAndReset(
+ .success(
+ Recording(url: url, duration: totalDuration)
+ ))
+ }
return
}
try? FileManager.default.removeItem(at: url)
let failure: Error
if !wasStopRequested,
- !VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
- stopRequested: false,
- error: error,
- recordedDuration: inspection?.duration ?? fallbackDuration,
- maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
- ) {
+ !VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
+ stopRequested: false,
+ error: error,
+ recordedDuration: inspection?.duration ?? fallbackDuration,
+ maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
+ )
+ {
failure = VideoNoteRecorderError.recordingInterrupted
} else if !VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error), let error {
failure = error
@@ -501,10 +718,11 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
return await withCheckedContinuation { continuation in
sessionQueue.async {
let seconds = CMTimeGetSeconds(output.recordedDuration)
- continuation.resume(returning: MovieOutputSnapshot(
- isRecording: output.isRecording,
- recordedDuration: seconds.isFinite && seconds > 0 ? seconds : 0
- ))
+ continuation.resume(
+ returning: MovieOutputSnapshot(
+ isRecording: output.isRecording,
+ recordedDuration: seconds.isFinite && seconds > 0 ? seconds : 0
+ ))
}
}
}
@@ -523,6 +741,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
continuation.resume()
}
}
+ // stopRunning is asynchronous: wait until the camera pipeline really
+ // released its media resources. The finalize re-encode must not start
+ // while the capture session still holds the encoder/muxer, otherwise
+ // the writer's inputs never become ready.
+ let stopDeadline = Date().addingTimeInterval(10)
+ while session.isRunning, Date() < stopDeadline {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
}
private func scheduleStartupTimeout(for url: URL) {
@@ -532,21 +758,23 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
nanoseconds: VideoNoteStopPolicy.startupTimeoutNanoseconds
)
guard !Task.isCancelled,
- let self,
- self.lifecycle == .starting,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ let self,
+ self.lifecycle == .starting,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
self.lifecycle = .stopping
await self.stopMovieOutput()
guard !Task.isCancelled,
- self.lifecycle == .stopping,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ self.lifecycle == .stopping,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
self.scheduleFinalizationTimeout(for: url)
}
}
@@ -558,12 +786,13 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
nanoseconds: VideoNoteStopPolicy.finalizationTimeoutNanoseconds
)
guard !Task.isCancelled,
- let self,
- self.lifecycle == .stopping,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ let self,
+ self.lifecycle == .stopping,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
// A few macOS camera drivers stop the file output but delay its
// delegate callback until the capture session itself is flushed.
@@ -574,42 +803,47 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
nanoseconds: VideoNoteStopPolicy.finalizationRecoveryGraceNanoseconds
)
guard !Task.isCancelled,
- self.lifecycle == .stopping,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ self.lifecycle == .stopping,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
let fallbackDuration = self.measuredWallClockDuration
let inspection = await self.fileInspector.inspect(
url: url,
fallbackDuration: fallbackDuration
)
guard !Task.isCancelled,
- self.lifecycle == .stopping,
- VideoNoteRecordingLifecycle.acceptsCompletion(
+ self.lifecycle == .stopping,
+ VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
- ) else { return }
+ )
+ else { return }
self.finalizationTimeoutTask = nil
if self.discardCurrentRecording {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(nil)
} else if let inspection,
- VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
- stopRequested: self.stopRequested,
- error: nil,
- recordedDuration: inspection.duration,
- maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
- ),
- VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) {
+ VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
+ stopRequested: self.stopRequested,
+ error: nil,
+ recordedDuration: inspection.duration,
+ maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
+ ),
+ VideoNoteStopPolicy.isValidFinalDuration(inspection.duration)
+ {
// Some macOS camera drivers finalize the .mov but omit the
// delegate callback. A playable file is more authoritative
// than the missing callback and remains safe to send.
- self.finishAndReset(.success(Recording(
- url: url,
- duration: inspection.duration
- )))
+ self.finishAndReset(
+ .success(
+ Recording(
+ url: url,
+ duration: inspection.duration
+ )))
} else if !self.stopRequested {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(.failure(VideoNoteRecorderError.recordingInterrupted))
@@ -639,6 +873,18 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
finalizationTimeoutTask = nil
fileValidationTask?.cancel()
fileValidationTask = nil
+ cameraSwitchContinuation = nil
+ cameraFlipTimeoutTask?.cancel()
+ cameraFlipTimeoutTask = nil
+ cameraFlipRequested = false
+ restartingSegmentForCameraFlip = false
+ usedFrontCamera = false
+ let segments = recordedSegments
+ recordedSegments = []
+ segmentsTotalDuration = 0
+ for segment in segments {
+ try? FileManager.default.removeItem(at: segment)
+ }
timer?.invalidate()
timer = nil
resetCaptureGraph()
@@ -652,10 +898,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
isPrepared = false
isRecording = false
isUsingFrontCamera = true
+ hasAlternateCamera = false
isMicrophoneMuted = false
-#if os(iOS)
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
-#endif
+ #if os(iOS)
+ try? AVAudioSession.sharedInstance().setActive(
+ false, options: .notifyOthersOnDeactivation)
+ #endif
}
private func waitForPreviousRecordingToFinish() async throws {
@@ -673,29 +921,46 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
private func configureVideoConnection() {
guard let connection = movieOutput.connection(with: .video) else { return }
-#if os(iOS)
- configureInteroperableVideoCodec(for: connection)
-#endif
+ #if os(iOS)
+ configureInteroperableVideoCodec(for: connection)
+ #endif
if connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = isUsingFrontCamera
}
-#if os(iOS)
- if connection.isVideoOrientationSupported {
- connection.videoOrientation = Self.currentVideoOrientation
- }
-#endif
+ #if os(iOS)
+ // iOS records upright portrait at capture time; macOS keeps the
+ // camera's native landscape orientation (the circle bubble crops
+ // it visually).
+ let rotationAngle = videoRotationAngle
+ if connection.isVideoRotationAngleSupported(rotationAngle) {
+ connection.videoRotationAngle = rotationAngle
+ }
+ #endif
}
-#if os(iOS)
- private func configureInteroperableVideoCodec(for connection: AVCaptureConnection) {
- guard movieOutput.availableVideoCodecTypes.contains(.h264) else { return }
- movieOutput.setOutputSettings(
- [AVVideoCodecKey: AVVideoCodecType.h264],
- for: connection
- )
- }
-#endif
+ #if os(iOS)
+ private var videoRotationAngle: CGFloat {
+ // The interface orientation is authoritative: it is what the user
+ // actually sees. The device sensor can claim landscape (or
+ // face-up) while the app stays portrait, and recording with the
+ // sensor value rotated portrait circles by 90 degrees.
+ VideoNoteRotationPolicy.angle(
+ for: VideoNoteRotationPolicy.currentInterfaceOrientation
+ )
+ }
+
+ #endif
+
+ #if os(iOS)
+ private func configureInteroperableVideoCodec(for connection: AVCaptureConnection) {
+ guard movieOutput.availableVideoCodecTypes.contains(.h264) else { return }
+ movieOutput.setOutputSettings(
+ [AVVideoCodecKey: AVVideoCodecType.h264],
+ for: connection
+ )
+ }
+ #endif
private func resetCaptureGraph() {
let session = session
@@ -721,6 +986,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
}
}
+ private func hasCamera(at position: AVCaptureDevice.Position) -> Bool {
+ AVCaptureDevice.default(
+ .builtInWideAngleCamera,
+ for: .video,
+ position: position
+ ) != nil
+ }
+
private func configureCaptureGraph() async throws -> CaptureGraph {
let session = session
let movieOutput = movieOutput
@@ -728,19 +1001,21 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
sessionQueue.async {
session.beginConfiguration()
do {
- session.sessionPreset = session.canSetSessionPreset(.vga640x480)
+ session.sessionPreset =
+ session.canSetSessionPreset(.vga640x480)
? .vga640x480
: .medium
-#if os(iOS)
- let camera = AVCaptureDevice.default(
- .builtInWideAngleCamera,
- for: .video,
- position: .front
- ) ?? AVCaptureDevice.default(for: .video)
-#else
- let camera = AVCaptureDevice.default(for: .video)
-#endif
+ #if os(iOS)
+ let camera =
+ AVCaptureDevice.default(
+ .builtInWideAngleCamera,
+ for: .video,
+ position: .front
+ ) ?? AVCaptureDevice.default(for: .video)
+ #else
+ let camera = AVCaptureDevice.default(for: .video)
+ #endif
guard let camera else {
throw VideoNoteRecorderError.cameraUnavailable
}
@@ -750,7 +1025,23 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
}
session.addInput(cameraInput)
- guard let microphone = AVCaptureDevice.default(for: .audio) else {
+ #if os(iOS)
+ // iOS routes the microphone by the active camera:
+ // the front camera gets the quiet earpiece mic and the
+ // back camera the loud bottom mic. Pin the bottom
+ // microphone explicitly so the voice level stays
+ // consistent no matter which camera is recording.
+ let microphone =
+ AVCaptureDevice.default(
+ .microphone,
+ for: .audio,
+ position: .back
+ )
+ ?? AVCaptureDevice.default(for: .audio)
+ #else
+ let microphone = AVCaptureDevice.default(for: .audio)
+ #endif
+ guard let microphone else {
throw VideoNoteRecorderError.microphoneUnavailable
}
let microphoneInput = try AVCaptureDeviceInput(device: microphone)
@@ -768,11 +1059,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
preferredTimescale: 600
)
session.commitConfiguration()
- continuation.resume(returning: CaptureGraph(
- cameraInput: cameraInput,
- microphoneInput: microphoneInput,
- usesFrontCamera: camera.position == .front
- ))
+ continuation.resume(
+ returning: CaptureGraph(
+ cameraInput: cameraInput,
+ microphoneInput: microphoneInput,
+ usesFrontCamera: camera.position == .front
+ ))
} catch {
session.commitConfiguration()
continuation.resume(throwing: error)
@@ -792,8 +1084,9 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe
}
}
guard !Task.isCancelled,
- sessionGeneration == generation,
- session.isRunning else {
+ sessionGeneration == generation,
+ session.isRunning
+ else {
throw CancellationError()
}
}
@@ -817,6 +1110,346 @@ private actor VideoNoteFileInspector {
let duration: TimeInterval
}
+ struct Finalization: Sendable {
+ let url: URL
+ let duration: TimeInterval
+ }
+
+ private struct Composition {
+ let asset: AVAsset
+ let videoTrack: AVMutableCompositionTrack
+ let transform: CGAffineTransform
+ let duration: TimeInterval
+ }
+
+ /// Hands the finished recording back for sending. The capture is
+ /// already rotated to portrait at record time, so no re-encode is needed:
+ /// the raw file is sent as-is and camera flips are merged with a
+ /// passthrough export (audio and video stay bit-exact). On iOS, when the
+ /// quiet front camera was used, the audio gets a deterministic volume
+ /// boost (see boostFrontCameraAudio).
+ func finalizeVideoNote(
+ segments: [URL],
+ frontCameraUsed: Bool
+ ) async -> Finalization? {
+ videoNoteFinalizeLogger.info(
+ "finalize start segments=\(segments.count) front=\(frontCameraUsed)"
+ )
+ #if os(iOS)
+ if frontCameraUsed, segments.count == 1 {
+ if let boosted = await Self.boostFrontCameraAudio(
+ segment: segments[0]
+ ) {
+ videoNoteFinalizeLogger.info("finalize boosted OK")
+ return boosted
+ }
+ videoNoteFinalizeLogger.info("finalize boost failed, raw")
+ }
+ #endif
+ if segments.count == 1 {
+ let duration =
+ (try? await AVURLAsset(url: segments[0]).load(.duration)
+ .seconds) ?? 0
+ videoNoteFinalizeLogger.info("finalize raw")
+ return Finalization(url: segments[0], duration: max(0, duration))
+ }
+ videoNoteFinalizeLogger.info("finalize passthrough")
+ return await Self.passthroughMerge(segments: segments)
+ }
+
+ /// Re-encodes a single segment with a fixed audio gain. The iOS front
+ /// camera routes the quiet earpiece microphone, and the system offers no
+ /// app-level way to override that routing — so the finished recording
+ /// gets a deterministic volume boost instead. The video frames are
+ /// copied verbatim (same dimensions and rotation transform); the audio
+ /// goes through an AVAudioMix that applies the gain without manual
+ /// sample math.
+ private static func boostFrontCameraAudio(
+ segment: URL
+ ) async -> Finalization? {
+ let asset = AVURLAsset(url: segment)
+ guard
+ let videoTrack = try? await asset.loadTracks(withMediaType: .video)
+ .first,
+ let audioTrack = try? await asset.loadTracks(withMediaType: .audio)
+ .first,
+ let duration = try? await asset.load(.duration)
+ else { return nil }
+ guard let outputURL = finalizationOutputURL() else { return nil }
+ let writer: AVAssetWriter
+ do {
+ writer = try AVAssetWriter(outputURL: outputURL, fileType: .mov)
+ } catch {
+ return nil
+ }
+ let size = try? await videoTrack.load(.naturalSize)
+ let width = max(1, Int(size?.width ?? 640))
+ let height = max(1, Int(size?.height ?? 480))
+ let videoInput = AVAssetWriterInput(
+ mediaType: .video,
+ outputSettings: [
+ AVVideoCodecKey: AVVideoCodecType.h264,
+ AVVideoWidthKey: width,
+ AVVideoHeightKey: height,
+ ]
+ )
+ videoInput.expectsMediaDataInRealTime = false
+ videoInput.transform = (try? await videoTrack.load(.preferredTransform)) ?? .identity
+ let adaptor = AVAssetWriterInputPixelBufferAdaptor(
+ assetWriterInput: videoInput,
+ sourcePixelBufferAttributes: [
+ kCVPixelBufferPixelFormatTypeKey as String:
+ kCVPixelFormatType_32BGRA,
+ kCVPixelBufferWidthKey as String: width,
+ kCVPixelBufferHeightKey as String: height,
+ ]
+ )
+ guard writer.canAdd(videoInput) else { return nil }
+ writer.add(videoInput)
+ let audioInput = AVAssetWriterInput(
+ mediaType: .audio,
+ outputSettings: [
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
+ AVSampleRateKey: 44_100,
+ AVNumberOfChannelsKey: 1,
+ AVEncoderBitRateKey: 96_000,
+ ]
+ )
+ audioInput.expectsMediaDataInRealTime = false
+ guard writer.canAdd(audioInput) else { return nil }
+ writer.add(audioInput)
+
+ let reader: AVAssetReader
+ do {
+ reader = try AVAssetReader(asset: asset)
+ } catch {
+ return nil
+ }
+ let videoOutput = AVAssetReaderTrackOutput(
+ track: videoTrack,
+ outputSettings: [
+ kCVPixelBufferPixelFormatTypeKey as String:
+ kCVPixelFormatType_32BGRA
+ ]
+ )
+ guard reader.canAdd(videoOutput) else { return nil }
+ reader.add(videoOutput)
+ // The reader applies the gain via the audio mix.
+ let mix = AVMutableAudioMix()
+ let parameters = AVMutableAudioMixInputParameters(track: audioTrack)
+ parameters.setVolume(3.5, at: .zero)
+ mix.inputParameters = [parameters]
+ let audioOutput = AVAssetReaderAudioMixOutput(
+ audioTracks: [audioTrack],
+ audioSettings: [
+ AVFormatIDKey: kAudioFormatLinearPCM
+ ]
+ )
+ audioOutput.audioMix = mix
+ guard reader.canAdd(audioOutput) else { return nil }
+ reader.add(audioOutput)
+
+ guard reader.startReading() else { return nil }
+ guard writer.startWriting() else {
+ try? FileManager.default.removeItem(at: outputURL)
+ return nil
+ }
+ guard let pool = adaptor.pixelBufferPool else {
+ writer.cancelWriting()
+ try? FileManager.default.removeItem(at: outputURL)
+ return nil
+ }
+
+ var sessionStarted = false
+ var videoSample = videoOutput.copyNextSampleBuffer()
+ var audioSample = audioOutput.copyNextSampleBuffer()
+ while videoSample != nil || audioSample != nil {
+ let videoPTS = videoSample.map {
+ CMSampleBufferGetPresentationTimeStamp($0)
+ }
+ let audioPTS = audioSample.map {
+ CMSampleBufferGetPresentationTimeStamp($0)
+ }
+ let takeVideo: Bool
+ switch (videoPTS, audioPTS) {
+ case let (video?, audio?): takeVideo = video <= audio
+ case (_?, nil): takeVideo = true
+ default: takeVideo = false
+ }
+ if takeVideo, let sample = videoSample {
+ videoSample = videoOutput.copyNextSampleBuffer()
+ guard let source = CMSampleBufferGetImageBuffer(sample)
+ else { continue }
+ let pts = CMSampleBufferGetPresentationTimeStamp(sample)
+ if !sessionStarted {
+ writer.startSession(atSourceTime: pts)
+ sessionStarted = true
+ }
+ while !videoInput.isReadyForMoreMediaData {
+ try? await Task.sleep(nanoseconds: 2_000_000)
+ }
+ var buffer: CVPixelBuffer?
+ CVPixelBufferPoolCreatePixelBuffer(
+ kCFAllocatorDefault, pool, &buffer
+ )
+ guard let buffer else { continue }
+ CVPixelBufferLockBaseAddress(source, .readOnly)
+ CVPixelBufferLockBaseAddress(buffer, [])
+ let srcBase = CVPixelBufferGetBaseAddress(source)!
+ let dstBase = CVPixelBufferGetBaseAddress(buffer)!
+ let srcBytes = CVPixelBufferGetBytesPerRow(source)
+ let dstBytes = CVPixelBufferGetBytesPerRow(buffer)
+ let copyHeight = min(
+ CVPixelBufferGetHeight(source),
+ CVPixelBufferGetHeight(buffer)
+ )
+ let copyBytes = min(srcBytes, dstBytes)
+ for row in 0.. Composition? {
+ let composition = AVMutableComposition()
+ guard
+ let videoTrack = composition.addMutableTrack(
+ withMediaType: .video,
+ preferredTrackID: kCMPersistentTrackID_Invalid
+ )
+ else { return nil }
+ // The audio track is created lazily and only when a segment really
+ // has audio. An empty audio track breaks the macOS exporter.
+ var compositionAudioTrack: AVMutableCompositionTrack?
+ var cursor = CMTime.zero
+ var transform = CGAffineTransform.identity
+ var hasVideo = false
+ for url in urls {
+ let asset = AVURLAsset(url: url)
+ guard
+ let duration = try? await asset.load(.duration),
+ let source = try? await asset.loadTracks(withMediaType: .video)
+ .first
+ else { return nil }
+ let range = CMTimeRange(start: .zero, duration: duration)
+ do {
+ try videoTrack.insertTimeRange(range, of: source, at: cursor)
+ if let audio = try? await asset.loadTracks(
+ withMediaType: .audio
+ ).first {
+ if compositionAudioTrack == nil {
+ compositionAudioTrack = composition.addMutableTrack(
+ withMediaType: .audio,
+ preferredTrackID: kCMPersistentTrackID_Invalid
+ )
+ }
+ if let compositionAudioTrack {
+ try? compositionAudioTrack.insertTimeRange(
+ range,
+ of: audio,
+ at: cursor
+ )
+ }
+ }
+ } catch {
+ return nil
+ }
+ if !hasVideo {
+ transform = (try? await source.load(.preferredTransform)) ?? .identity
+ hasVideo = true
+ }
+ cursor = cursor + duration
+ }
+ videoTrack.preferredTransform = transform
+ return Composition(
+ asset: composition,
+ videoTrack: videoTrack,
+ transform: transform,
+ duration: cursor.seconds
+ )
+ }
+
+ private static func passthroughMerge(
+ segments: [URL]
+ ) async -> Finalization? {
+ guard let composition = await composition(from: segments) else {
+ return nil
+ }
+ guard let outputURL = finalizationOutputURL() else { return nil }
+ guard let exporter = AVAssetExportSession(
+ asset: composition.asset,
+ presetName: AVAssetExportPresetPassthrough
+ ) else { return nil }
+ exporter.outputURL = outputURL
+ exporter.outputFileType = .mov
+ exporter.shouldOptimizeForNetworkUse = true
+ do {
+ try await exporter.export(to: outputURL, as: .mov)
+ } catch {
+ try? FileManager.default.removeItem(at: outputURL)
+ return nil
+ }
+ return Finalization(url: outputURL, duration: composition.duration)
+ }
+
+ private static func finalizationOutputURL() -> URL? {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("LumaRecordings", isDirectory: true)
+ do {
+ try FileManager.default.createDirectory(
+ at: directory,
+ withIntermediateDirectories: true
+ )
+ return directory.appendingPathComponent(
+ "video-note-final-\(UUID().uuidString).mov"
+ )
+ } catch {
+ return nil
+ }
+ }
+
func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? {
let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000]
for delay in retryDelays {
@@ -824,16 +1457,18 @@ private actor VideoNoteFileInspector {
try? await Task.sleep(nanoseconds: delay)
}
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
- values.isRegularFile == true,
- let size = values.fileSize,
- size > 1_024 else { continue }
+ values.isRegularFile == true,
+ let size = values.fileSize,
+ size > 1_024
+ else { continue }
let asset = AVURLAsset(url: url)
guard let duration = try? await asset.load(.duration),
- duration.seconds.isFinite,
- duration.seconds > 0.04,
- let tracks = try? await asset.loadTracks(withMediaType: .video),
- !tracks.isEmpty else { continue }
+ duration.seconds.isFinite,
+ duration.seconds > 0.04,
+ let tracks = try? await asset.loadTracks(withMediaType: .video),
+ !tracks.isEmpty
+ else { continue }
return Inspection(duration: duration.seconds)
}
@@ -842,9 +1477,10 @@ private actor VideoNoteFileInspector {
// of macOS camera drivers that delay movie metadata longer than the
// delegate callback itself.
guard fallbackDuration > 0.25,
- let values = try? url.resourceValues(forKeys: [.fileSizeKey]),
- let size = values.fileSize,
- size > 16_384 else { return nil }
+ let values = try? url.resourceValues(forKeys: [.fileSizeKey]),
+ let size = values.fileSize,
+ size > 16_384
+ else { return nil }
return Inspection(duration: fallbackDuration)
}
}
@@ -871,22 +1507,22 @@ enum VideoNoteStopPolicy {
}
}
-#if os(iOS)
-private extension VideoNoteRecorder {
- static var currentVideoOrientation: AVCaptureVideoOrientation {
- switch UIDevice.current.orientation {
- case .portraitUpsideDown:
- return .portraitUpsideDown
- case .landscapeLeft:
- return .landscapeRight
- case .landscapeRight:
- return .landscapeLeft
- default:
- return .portrait
- }
- }
-}
-#endif
+// #if os(iOS)
+// extension VideoNoteRecorder {
+// fileprivate static var currentVideoOrientation: AVCaptureVideoOrientation {
+// switch UIDevice.current.orientation {
+// case .portraitUpsideDown:
+// return .portraitUpsideDown
+// case .landscapeLeft:
+// return .landscapeRight
+// case .landscapeRight:
+// return .landscapeLeft
+// default:
+// return .portrait
+// }
+// }
+// }
+// #endif
private enum VideoNoteRecorderError: LocalizedError {
case cameraPermissionDenied
@@ -920,7 +1556,8 @@ private enum VideoNoteRecorderError: LocalizedError {
case .recordingBusy:
return "Предыдущая запись ещё завершается. Подождите секунду и повторите."
case .recordingInterrupted:
- return "Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись."
+ return
+ "Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись."
case .finalizationTimedOut:
return "Камера не смогла завершить видеофайл. Повторите запись."
case .emptyRecording:
diff --git a/Sources/Shared/UI/AppLockView.swift b/Sources/Shared/UI/AppLockView.swift
new file mode 100644
index 0000000..4f1bf1d
--- /dev/null
+++ b/Sources/Shared/UI/AppLockView.swift
@@ -0,0 +1,334 @@
+import LocalAuthentication
+import SwiftUI
+
+@MainActor
+struct AppLockView: View {
+ @ObservedObject var model: AppModel
+ @State private var passcode = ""
+ @State private var attemptFailed = false
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ ZStack {
+ LinearGradient(
+ colors: [
+ Color(red: 0.08, green: 0.38, blue: 0.78),
+ Color(red: 0.12, green: 0.62, blue: 0.96),
+ Color(red: 0.44, green: 0.84, blue: 0.96),
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ .ignoresSafeArea()
+
+ VStack(spacing: 22) {
+ Image(systemName: "lock.fill")
+ .font(.system(size: 44, weight: .semibold))
+ .foregroundStyle(.white)
+
+ Text("Luma заблокирован")
+ .font(.title2.weight(.bold))
+ .foregroundStyle(.white)
+
+ SecureField("Пароль", text: $passcode)
+ .focused($isFocused)
+ .textContentType(.password)
+ #if os(iOS)
+ .textInputAutocapitalization(.never)
+ #endif
+ .autocorrectionDisabled()
+ .textFieldStyle(.roundedBorder)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 260)
+ .onSubmit(submit)
+
+ if attemptFailed {
+ Text("Неверный пароль")
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.white.opacity(0.95))
+ }
+
+ Button(action: submit) {
+ Text("Разблокировать")
+ .fontWeight(.semibold)
+ .frame(maxWidth: 260)
+ .frame(height: 34)
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(passcode.isEmpty)
+
+ if model.biometricUnlockAvailable,
+ model.appLockBiometricIsEnabled
+ {
+ Button {
+ Task { await biometricUnlock() }
+ } label: {
+ Label(
+ "Войти по \(model.biometricUnlockName)",
+ systemImage: model.biometricUnlockName == "Face ID"
+ ? "faceid" : "touchid"
+ )
+ .foregroundStyle(.white)
+ }
+ }
+ }
+ .padding(28)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: 28, style: .continuous)
+ )
+ .padding(24)
+ }
+ .onAppear {
+ isFocused = true
+ }
+ #if os(iOS)
+ .task {
+ // Auto-prompt biometrics once when the lock screen appears. iOS
+ // only: on macOS the system Touch ID dialog blocks the window
+ // and freezes the lock screen.
+ guard model.biometricUnlockAvailable,
+ model.appLockBiometricIsEnabled
+ else {
+ return
+ }
+ await biometricUnlock()
+ }
+ #endif
+ }
+
+ private func submit() {
+ guard !passcode.isEmpty else { return }
+ if model.unlockWithPasscode(passcode) {
+ attemptFailed = false
+ passcode = ""
+ } else {
+ attemptFailed = true
+ passcode = ""
+ }
+ }
+
+ private func biometricUnlock() async {
+ _ = await model.unlockWithBiometrics()
+ }
+}
+
+/// Configurable passcode sheet: setting up a new passcode, changing it, or
+/// verifying the current one to disable the lock / toggle biometrics.
+@MainActor
+struct AppLockPasscodeSheet: View {
+ enum Mode: Identifiable {
+ case setup
+ case change
+ case verify(VerificationAction)
+
+ enum VerificationAction: String {
+ case disableLock
+ case enableBiometrics
+ case disableBiometrics
+ }
+
+ var id: String {
+ switch self {
+ case .setup: return "setup"
+ case .change: return "change"
+ case .verify(let action): return "verify-\(action.rawValue)"
+ }
+ }
+ }
+
+ let model: AppModel
+ let mode: Mode
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var oldPasscode = ""
+ @State private var passcode = ""
+ @State private var repeatedPasscode = ""
+ @State private var errorText: String?
+
+ var body: some View {
+ #if os(iOS)
+ NavigationStack {
+ Form {
+ formcontent()
+ }
+ .navigationTitle(navigationTitle)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Отмена") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Готово", action: submit)
+ .disabled(!canSubmit)
+ }
+ }
+ }
+ #else
+ List {
+ formcontent()
+ }
+ .navigationTitle(navigationTitle)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Отмена") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Готово", action: submit)
+ .disabled(!canSubmit)
+ }
+ }
+ .frame(minWidth: 420, minHeight: 320)
+ #endif
+ }
+
+ @ViewBuilder
+ private func formcontent() -> some View {
+ switch mode {
+ case .setup:
+ Section(title) {
+ SecureField(fieldLabel, text: $passcode)
+ .textContentType(.password)
+ SecureField("Повторите пароль", text: $repeatedPasscode)
+ .textContentType(.password)
+ }
+ case .verify:
+ Section("Текущий пароль") {
+ SecureField("Текущий пароль", text: $oldPasscode)
+ .textContentType(.password)
+ }
+ case .change:
+ Section("Текущий пароль") {
+ SecureField("Текущий пароль", text: $oldPasscode)
+ .textContentType(.password)
+ }
+ Section(title) {
+ SecureField(fieldLabel, text: $passcode)
+ .textContentType(.password)
+ SecureField("Повторите пароль", text: $repeatedPasscode)
+ .textContentType(.password)
+ }
+ }
+ if let errorText {
+ Section {
+ Text(errorText)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ }
+
+ private var title: String {
+ switch mode {
+ case .setup: return "Новый пароль"
+ case .change: return "Новый пароль"
+ case .verify: return "Пароль"
+ }
+ }
+
+ private var navigationTitle: String {
+ switch mode {
+ case .setup: return "Включить блокировку"
+ case .change: return "Сменить пароль"
+ case .verify(let action):
+ switch action {
+ case .disableLock: return "Выключить блокировку"
+ case .enableBiometrics, .disableBiometrics:
+ return "Подтвердите пароль"
+ }
+ }
+ }
+
+ private var fieldLabel: String {
+ switch mode {
+ case .setup, .change:
+ return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)"
+ case .verify: return "Пароль"
+ }
+ }
+
+ private var canSubmit: Bool {
+ switch mode {
+ case .setup:
+ return !passcode.isEmpty && !repeatedPasscode.isEmpty
+ case .change:
+ return !oldPasscode.isEmpty && !passcode.isEmpty
+ && !repeatedPasscode.isEmpty
+ case .verify:
+ return !oldPasscode.isEmpty
+ }
+ }
+
+ private func submit() {
+ errorText = nil
+ switch mode {
+ case .setup:
+ guard AppLockPolicy.isValid(passcode) else {
+ errorText =
+ "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
+ return
+ }
+ guard passcode == repeatedPasscode else {
+ errorText = "Пароли не совпадают."
+ repeatedPasscode = ""
+ return
+ }
+ if model.enableAppLock(passcode: passcode) {
+ dismiss()
+ } else {
+ errorText =
+ model.errorMessage ?? "Не удалось включить блокировку."
+ }
+ case .change:
+ guard AppLockPolicy.isValid(passcode) else {
+ errorText =
+ "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
+ return
+ }
+ guard passcode == repeatedPasscode else {
+ errorText = "Пароли не совпадают."
+ repeatedPasscode = ""
+ return
+ }
+ if model.changeAppLockPasscode(from: oldPasscode, to: passcode) {
+ dismiss()
+ } else {
+ errorText = "Неверный текущий пароль."
+ oldPasscode = ""
+ }
+ case .verify(let action):
+ switch action {
+ case .disableLock:
+ if model.disableAppLock(passcode: oldPasscode) {
+ dismiss()
+ } else {
+ errorText = "Неверный пароль."
+ oldPasscode = ""
+ }
+ case .enableBiometrics:
+ if model.setAppLockBiometricUnlock(true, passcode: oldPasscode)
+ {
+ dismiss()
+ } else {
+ errorText = "Неверный пароль."
+ oldPasscode = ""
+ }
+ case .disableBiometrics:
+ if model.setAppLockBiometricUnlock(false, passcode: oldPasscode)
+ {
+ dismiss()
+ } else {
+ errorText = "Неверный пароль."
+ oldPasscode = ""
+ }
+ }
+ }
+ }
+}
+
+#Preview("Экран блокировки") {
+ AppLockView(model: PreviewSupport.model)
+}
+
+#Preview("Настройка пароля") {
+ AppLockPasscodeSheet(model: PreviewSupport.model, mode: .setup)
+}
diff --git a/Sources/Shared/UI/AttachmentPreviewView.swift b/Sources/Shared/UI/AttachmentPreviewView.swift
index 6bc5525..f428d0a 100644
--- a/Sources/Shared/UI/AttachmentPreviewView.swift
+++ b/Sources/Shared/UI/AttachmentPreviewView.swift
@@ -1,4 +1,5 @@
import Foundation
+import os
import SwiftUI
#if os(iOS)
@@ -239,7 +240,13 @@ struct AttachmentPreviewView: View {
thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) }
for draft in drafts where thumbnailImages[draft.id] == nil {
guard let data = draft.thumbnailData,
- let image = DraftThumbnailImage(data: data) else { continue }
+ let image = DraftThumbnailImage(data: data) else {
+ if draft.kind == .video {
+ Logger(subsystem: "Luma", category: "video-preview")
+ .warning("video draft has no decodable thumbnail: \(draft.filename) thumbnailBytes=\(draft.thumbnailData?.count ?? 0)")
+ }
+ continue
+ }
thumbnailImages[draft.id] = image
await Task.yield()
}
@@ -291,3 +298,19 @@ struct AttachmentPreviewView: View {
}
}
}
+
+private struct AttachmentPreviewHost: View {
+ @State private var drafts = PreviewSupport.drafts()
+
+ var body: some View {
+ AttachmentPreviewView(
+ model: PreviewSupport.model,
+ conversation: PreviewSupport.conversation(displayName: "Алиса"),
+ drafts: $drafts
+ )
+ }
+}
+
+#Preview {
+ AttachmentPreviewHost()
+}
diff --git a/Sources/Shared/UI/CallView.swift b/Sources/Shared/UI/CallView.swift
index 4ea1957..72576bb 100644
--- a/Sources/Shared/UI/CallView.swift
+++ b/Sources/Shared/UI/CallView.swift
@@ -8,7 +8,7 @@ struct CallView: View {
let call: CallSnapshot
private var conversation: Conversation {
- model.conversations.first(where: { $0.id == call.peerJID.lowercased() })
+ model.conversations.first(where: { $0.jid == call.peerJID.lowercased() })
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
}
@@ -241,3 +241,18 @@ private struct CallControlButton: View {
.accessibilityLabel(title)
}
}
+
+#Preview("Входящий видеозвонок") {
+ CallView(model: PreviewSupport.model, call: PreviewSupport.callSnapshot())
+}
+
+#Preview("Аудиозвонок") {
+ CallView(
+ model: PreviewSupport.model,
+ call: PreviewSupport.callSnapshot(
+ media: [.audio],
+ phase: .connected,
+ isSpeakerEnabled: true
+ )
+ )
+}
diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift
index 748dd32..6bbc99d 100644
--- a/Sources/Shared/UI/ChatView.swift
+++ b/Sources/Shared/UI/ChatView.swift
@@ -2,17 +2,32 @@ import CoreTransferable
import Foundation
import ImageIO
import PhotosUI
+import SwiftData
import SwiftUI
import UniformTypeIdentifiers
+import os
#if os(iOS)
-import UIKit
+ import UIKit
#endif
+private enum EmojiPickerPresentation: Identifiable {
+ case reaction(ChatMessage)
+ case composer
+
+ var id: String {
+ switch self {
+ case .reaction(let message): return "reaction-\(message.clientID)"
+ case .composer: return "composer"
+ }
+ }
+}
+
@MainActor
struct ChatView: View {
private static let bottomAnchorID = "luma-chat-timeline-bottom"
private static let timelineCoordinateSpace = "luma-chat-timeline-space"
+ private static let localHistoryPageSize = 60
@ObservedObject var model: AppModel
let conversation: Conversation
@@ -42,8 +57,15 @@ struct ChatView: View {
@State private var selectedMessageIDs: Set = []
@State private var destructiveAction: MessageDestructiveAction?
@State private var replyThreadSelection: ReplyThreadSelection?
+ @State private var emojiPickerPresentation: EmojiPickerPresentation?
@State private var hasCompletedInitialScroll = false
@State private var isNearTimelineBottom = true
+ @State private var historyLoadAnchorID: String?
+ @State private var historyTopTriggerArmed = true
+ @State private var historyTopTriggerVisible = false
+ @State private var historyLoadEntryCount = 0
+ @State private var historyAutoContinueCount = 0
+ @State private var emptyHistoryRetryCount = 0
@State private var activeCaptureMode: ComposerCaptureMode?
@State private var preparingCaptureMode: ComposerCaptureMode?
@State private var captureGestureIsActive = false
@@ -52,6 +74,94 @@ struct ChatView: View {
@State private var captureAttemptID = UUID()
@State private var captureStartTask: Task?
@State private var captureSuspendsArchiveSync = false
+ @State private var pickerResetToken = UUID()
+ @State private var timelineEntries: [ChatTimelineEntry] = []
+ @State private var visibleLocalMessageCount = 0
+ @State private var localHistoryExhausted = false
+ /// Messages-style entrance state: which rows currently animate, which IDs
+ /// the timeline already showed, and whether the opening cascade of this
+ /// conversation has played.
+ @State private var entranceModes: [String: MessageBubbleEntrance.Mode] = [:]
+ @State private var knownEntranceIDs: Set = []
+ @State private var hasPlayedOpeningEntrance = false
+ @State private var entranceReleaseToken = UUID()
+
+ /// Messages of this conversation straight from the SwiftData store,
+ /// ordered like `AppModel.selectedMessages`: timestamp, then clientID.
+ @Query private var messages: [ChatMessage]
+
+ init(model: AppModel, conversation: Conversation) {
+ self.model = model
+ self.conversation = conversation
+ let conversationJID = conversation.jid
+ _messages = Query(
+ filter: #Predicate { message in
+ message.conversationID == conversationJID
+ },
+ sort: [
+ SortDescriptor(\ChatMessage.timestamp, order: .forward),
+ SortDescriptor(\ChatMessage.clientID, order: .forward),
+ ]
+ )
+ _audioRecorder = StateObject(wrappedValue: AudioMessageRecorder())
+ _isComposerFocused = FocusState()
+ _draft = State(initialValue: "")
+ _showingFileImporter = State(initialValue: false)
+ _fileImportMode = State(initialValue: FileImportMode.files)
+ _showingEncryption = State(initialValue: false)
+ _showingVideoNoteRecorder = State(initialValue: false)
+ _videoNoteIsSending = State(initialValue: false)
+ _showingMediaPicker = State(initialValue: false)
+ _showingPhotoCamera = State(initialValue: false)
+ _photoCameraIsPreparingResult = State(initialValue: false)
+ _showingLocationPicker = State(initialValue: false)
+ _showingGroupInfo = State(initialValue: false)
+ _showingAttachmentPreview = State(initialValue: false)
+ _attachmentPreviewPresentationPending = State(initialValue: false)
+ _attachmentPreviewPresentationTask = State?>(
+ initialValue: nil
+ )
+ _pickedMediaItems = State<[PhotosPickerItem]>(initialValue: [])
+ _attachmentDrafts = State<[AttachmentDraft]>(initialValue: [])
+ _isPreparingAttachments = State(initialValue: false)
+ _editingMessageID = State(initialValue: nil)
+ _replyingToMessageID = State(initialValue: nil)
+ _forwardingSelection = State(
+ initialValue: nil
+ )
+ _selectedMessageIDs = State>(initialValue: [])
+ _destructiveAction = State(initialValue: nil)
+ _replyThreadSelection = State(initialValue: nil)
+ _emojiPickerPresentation = State(
+ initialValue: nil
+ )
+ _hasCompletedInitialScroll = State(initialValue: false)
+ _historyTopTriggerVisible = State(initialValue: false)
+ _historyLoadEntryCount = State(initialValue: 0)
+ _historyAutoContinueCount = State(initialValue: 0)
+ _emptyHistoryRetryCount = State(initialValue: 0)
+ _isNearTimelineBottom = State(initialValue: true)
+ _historyLoadAnchorID = State(initialValue: nil)
+ _historyTopTriggerArmed = State(initialValue: true)
+ _activeCaptureMode = State(initialValue: nil)
+ _preparingCaptureMode = State(initialValue: nil)
+ _captureGestureIsActive = State(initialValue: false)
+ _captureIsLocked = State(initialValue: false)
+ _captureDragTranslation = State(initialValue: CGSize.zero)
+ _captureAttemptID = State(initialValue: UUID())
+ _captureStartTask = State?>(initialValue: nil)
+ _captureSuspendsArchiveSync = State(initialValue: false)
+ _pickerResetToken = State(initialValue: UUID())
+ _timelineEntries = State<[ChatTimelineEntry]>(initialValue: [])
+ _visibleLocalMessageCount = State(initialValue: 0)
+ _localHistoryExhausted = State(initialValue: false)
+ _entranceModes = State<[String: MessageBubbleEntrance.Mode]>(
+ initialValue: [:]
+ )
+ _knownEntranceIDs = State>(initialValue: [])
+ _hasPlayedOpeningEntrance = State(initialValue: false)
+ _entranceReleaseToken = State(initialValue: UUID())
+ }
var body: some View {
VStack(spacing: 0) {
@@ -68,10 +178,10 @@ struct ChatView: View {
}
}
.background(Color.secondary.opacity(0.035))
- .navigationTitle(conversation.displayName)
-#if os(iOS)
- .navigationBarTitleDisplayMode(.inline)
-#endif
+ .navigationTitle("")
+ // #if os(iOS)
+ // .navigationBarTitleDisplayMode(.inline)
+ // #endif
.toolbar {
ToolbarItem(placement: .principal) {
chatNavigationTitle
@@ -85,42 +195,61 @@ struct ChatView: View {
.font(.subheadline.weight(.semibold))
}
} else {
- ToolbarItemGroup(placement: .primaryAction) {
+ ToolbarItem(placement: .primaryAction) {
if !conversation.isGroup {
- Button {
- Task { await model.startCall(to: conversation.jid, withVideo: false) }
- } label: {
- Image(systemName: "phone")
- }
- .disabled(!canStartCall)
- .help("Аудиозвонок")
+ Menu {
+ Button {
+ Task {
+ await model.startCall(
+ to: conversation.jid,
+ withVideo: false
+ )
+ }
+ } label: {
+ Label("Аудиозвонок", systemImage: "phone")
+ }
+ .disabled(!canStartCall)
+ .help("Аудиозвонок")
- Button {
- Task { await model.startCall(to: conversation.jid, withVideo: true) }
+ Button {
+ Task {
+ await model.startCall(
+ to: conversation.jid,
+ withVideo: true
+ )
+ }
+ } label: {
+ Label("Видеозвонок", systemImage: "video")
+ }
+ .disabled(!canStartCall)
+ .help("Видеозвонок")
+ encryptionMenu
} label: {
- Image(systemName: "video")
+ Image(systemName: "ellipsis.circle")
}
+ .menuIndicator(.hidden)
.disabled(!canStartCall)
- .help("Видеозвонок")
}
- AvatarView(
- conversation: liveConversation,
- imageData: model.avatarData(for: conversation.jid),
- size: 28
- )
if conversation.isGroup {
- Button { showingGroupInfo = true } label: {
+ Button {
+ showingGroupInfo = true
+ } label: {
Image(systemName: "person.3")
}
.help("Информация о группе")
}
- encryptionMenu
}
}
}
-#if os(iOS)
- .toolbar(replyThreadSelection == nil ? .visible : .hidden, for: .navigationBar)
-#endif
+ #if os(iOS)
+ .toolbar(
+ replyThreadSelection == nil ? .visible : .hidden,
+ for: .navigationBar
+ )
+ // A pushed conversation takes the whole screen; the tab bar
+ // comes back when the user pops back to the list.
+ .toolbar(.hidden, for: .tabBar)
+ #endif
.fileImporter(
isPresented: $showingFileImporter,
allowedContentTypes: fileImportMode.allowedContentTypes,
@@ -128,7 +257,9 @@ struct ChatView: View {
) { result in
guard case .success(let urls) = result else { return }
let preferredKind = fileImportMode.preferredKind
- Task { await stageImportedFiles(urls, preferredKind: preferredKind) }
+ Task {
+ await stageImportedFiles(urls, preferredKind: preferredKind)
+ }
}
.photosPicker(
isPresented: $showingMediaPicker,
@@ -137,43 +268,59 @@ struct ChatView: View {
matching: .any(of: [.images, .videos]),
preferredItemEncoding: .current
)
+ // A fresh picker identity per attempt: clearing the selection while
+ // the picker is presented desynchronizes its internal state, and the
+ // stale selection then leaks into the next presentation.
+ .id(pickerResetToken)
.onChange(of: showingMediaPicker) { _, isPresented in
- if !isPresented {
- schedulePendingAttachmentPreview()
- }
- }
-#if os(iOS)
- .fullScreenCover(
- isPresented: $showingPhotoCamera,
- onDismiss: {
- schedulePendingAttachmentPreview()
- if !photoCameraIsPreparingResult {
- releaseArchiveSyncAfterCapture()
- }
- }
- ) {
- SystemPhotoCameraView(
- onMedia: { result in
- showingPhotoCamera = false
- switch result {
- case .success(let media):
- photoCameraIsPreparingResult = true
- Task { await stageCapturedMedia(media) }
- case .failure(let error):
- model.errorMessage = error.localizedDescription
- }
- },
- onCancel: {
- showingPhotoCamera = false
- }
- )
- .ignoresSafeArea()
- }
-#endif
- .onChange(of: pickedMediaItems) { _, items in
+ guard !isPresented else { return }
+ // On Done the binding holds the chosen items; on Cancel SwiftUI
+ // restores the pre-presentation value (kept empty below), so
+ // staging runs only for a confirmed selection.
+ let items = pickedMediaItems
+ pickedMediaItems = []
+ pickerResetToken = UUID()
guard !items.isEmpty else { return }
Task { await stagePickedMedia(items) }
}
+ #if os(iOS)
+ .fullScreenCover(
+ isPresented: $showingPhotoCamera,
+ onDismiss: {
+ schedulePendingAttachmentPreview()
+ if !photoCameraIsPreparingResult {
+ releaseArchiveSyncAfterCapture()
+ }
+ }
+ ) {
+ SystemPhotoCameraView(
+ onDismissRequest: {
+ // Runs synchronously in the picker delegate while the
+ // camera is still presented: dismiss through the
+ // binding so SwiftUI owns the transition, and reserve
+ // the archive-sync suspension across the dismissal
+ // while the file is prepared in the background.
+ showingPhotoCamera = false
+ photoCameraIsPreparingResult = true
+ },
+ onMedia: { result in
+ switch result {
+ case .success(let media):
+ photoCameraIsPreparingResult = true
+ Task { await stageCapturedMedia(media) }
+ case .failure(let error):
+ photoCameraIsPreparingResult = false
+ releaseArchiveSyncAfterCapture()
+ model.errorMessage = error.localizedDescription
+ }
+ },
+ onCancel: {
+ showingPhotoCamera = false
+ }
+ )
+ .ignoresSafeArea()
+ }
+ #endif
.onChange(of: draft) { _, text in
model.updateComposerActivity(text, in: liveConversation)
}
@@ -221,6 +368,11 @@ struct ChatView: View {
clearMessageSelection()
}
}
+ .sheet(item: $emojiPickerPresentation) { presentation in
+ EmojiPickerView { emoji in
+ handleEmojiSelection(emoji, for: presentation)
+ }
+ }
.alert(item: $destructiveAction) { action in
Alert(
title: Text(action.title),
@@ -234,11 +386,23 @@ struct ChatView: View {
.overlay {
replyThreadOverlay
}
- .onChange(of: conversation.id) { _, _ in
+ .onChange(of: conversation.jid) { _, _ in
hasCompletedInitialScroll = false
isNearTimelineBottom = true
+ historyLoadAnchorID = nil
+ historyTopTriggerArmed = true
+ historyTopTriggerVisible = false
+ historyAutoContinueCount = 0
+ emptyHistoryRetryCount = 0
+ visibleLocalMessageCount = 0
+ localHistoryExhausted = false
+ entranceModes = [:]
+ knownEntranceIDs = []
+ hasPlayedOpeningEntrance = false
+ entranceReleaseToken = UUID()
}
.onDisappear {
+ model.endConversationViewing(jid: conversation.jid)
model.endComposerActivity(in: liveConversation)
attachmentPreviewPresentationTask?.cancel()
attachmentPreviewPresentationTask = nil
@@ -247,9 +411,10 @@ struct ChatView: View {
clearMessageSelection()
cancelComposerRecording(feedback: false)
if !showingVideoNoteRecorder,
- !showingPhotoCamera,
- !videoNoteIsSending,
- !photoCameraIsPreparingResult {
+ !showingPhotoCamera,
+ !videoNoteIsSending,
+ !photoCameraIsPreparingResult
+ {
releaseArchiveSyncAfterCapture()
}
if !showingAttachmentPreview {
@@ -262,7 +427,11 @@ struct ChatView: View {
@ViewBuilder
private var replyThreadOverlay: some View {
if let selection = replyThreadSelection,
- let root = model.message(withID: selection.rootID, in: conversation.id) {
+ let root = model.message(
+ withID: selection.rootID,
+ in: conversation.jid
+ )
+ {
ReplyThreadOverlay(
model: model,
rootMessage: root,
@@ -279,12 +448,21 @@ struct ChatView: View {
private var encryptionMenu: some View {
Menu {
- Section(conversation.isGroup ? "Шифрование этой группы" : "Шифрование этого чата") {
- ForEach(EncryptionPreference.allCases, id: \.rawValue) { preference in
+ Section(
+ conversation.isGroup
+ ? "Шифрование этой группы" : "Шифрование этого чата"
+ ) {
+ ForEach(EncryptionPreference.allCases, id: \.rawValue) {
+ preference in
Button {
- model.setEncryptionPreference(preference, for: conversation.jid)
+ model.setEncryptionPreference(
+ preference,
+ for: conversation.jid
+ )
} label: {
- if model.encryptionPreference(for: conversation.jid) == preference {
+ if model.encryptionPreference(for: conversation.jid)
+ == preference
+ {
Label(preference.title, systemImage: "checkmark")
} else {
Text(preference.title)
@@ -294,15 +472,22 @@ struct ChatView: View {
}
if !conversation.isGroup {
Divider()
- Button { showingEncryption = true } label: {
- Label("Устройства и отпечатки", systemImage: "checkmark.shield")
+ Button {
+ showingEncryption = true
+ } label: {
+ Label(
+ "Устройства и отпечатки",
+ systemImage: "checkmark.shield"
+ )
}
}
} label: {
- Image(systemName: encryptionIcon)
- .foregroundStyle(encryptionEnabled ? Color.primary : Color.orange)
+ Label("Шифрование", systemImage: encryptionIcon)
}
- .help(encryptionEnabled ? "OMEMO включено" : "Сообщения отправляются без OMEMO")
+ .help(
+ encryptionEnabled
+ ? "OMEMO включено" : "Сообщения отправляются без OMEMO"
+ )
}
private var canStartCall: Bool {
@@ -310,7 +495,106 @@ struct ChatView: View {
}
private var selectedTimelineMessages: [ChatMessage] {
- model.selectedMessages.filter { selectedMessageIDs.contains($0.id) }
+ model.selectedMessages.filter {
+ selectedMessageIDs.contains($0.clientID)
+ }
+ }
+
+ private func rebuildTimelineEntries(from newMessages: [ChatMessage]? = nil) {
+ let source = newMessages ?? messages
+ if visibleLocalMessageCount == 0 {
+ visibleLocalMessageCount = min(Self.localHistoryPageSize, source.count)
+ } else if localHistoryExhausted {
+ visibleLocalMessageCount = source.count
+ } else {
+ visibleLocalMessageCount = min(visibleLocalMessageCount, source.count)
+ }
+ localHistoryExhausted = visibleLocalMessageCount >= source.count
+ let visible = Array(source.suffix(visibleLocalMessageCount))
+ timelineEntries = ChatTimelineEntry.make(from: visible)
+ updateEntranceModes(for: visible)
+ }
+
+ private var hasMoreLocalHistory: Bool {
+ visibleLocalMessageCount < messages.count
+ }
+
+ /// Decides which rows play a Messages-style entrance after a rebuild.
+ /// The first page of an opened conversation cascades bottom-up; rows that
+ /// appear later (a send, an incoming message) launch from the composer's
+ /// edge. Modes are released once the animation window closes so a row
+ /// recycled by the lazy stack during scrolling reappears statically.
+ private func updateEntranceModes(for newMessages: [ChatMessage]) {
+ let ids = newMessages.map(\.clientID)
+ let known = knownEntranceIDs
+ knownEntranceIDs = Set(ids)
+
+ guard hasPlayedOpeningEntrance else {
+ guard !ids.isEmpty else { return }
+ hasPlayedOpeningEntrance = true
+ // The first message ever in an empty chat should launch out of
+ // the composer like any other send, not cascade in.
+ if ids.count == 1, known.isEmpty,
+ newMessages.first?.direction == .outgoing
+ {
+ applyLaunch(for: newMessages, ids: ids)
+ return
+ }
+ let delays = ChatMessageEntrancePolicy.entranceDelays(
+ forIDs: ids
+ )
+ for (id, delay) in delays {
+ entranceModes[id] = .cascade(delay: delay)
+ }
+ scheduleEntranceRelease(
+ ids: Set(delays.keys),
+ after: ChatMessageEntrancePolicy.entranceCascadeDuration
+ )
+ return
+ }
+
+ applyLaunch(
+ for: newMessages,
+ ids: ChatMessageEntrancePolicy.appendedLaunchIDs(
+ in: ids,
+ after: known
+ )
+ )
+ }
+
+ private func applyLaunch(for messages: [ChatMessage], ids: [String]) {
+ guard !ids.isEmpty else { return }
+ let launched = Set(ids)
+ var applied: Set = []
+ for message in messages where launched.contains(message.clientID) {
+ entranceModes[message.clientID] = .launch(
+ outgoing: message.direction == .outgoing
+ )
+ applied.insert(message.clientID)
+ }
+ scheduleEntranceRelease(
+ ids: applied,
+ after: ChatMessageEntrancePolicy.entranceAnimationDuration
+ )
+ }
+
+ private func scheduleEntranceRelease(
+ ids: Set,
+ after delay: TimeInterval
+ ) {
+ guard !ids.isEmpty else { return }
+ let token = entranceReleaseToken
+ Task { @MainActor in
+ try? await Task.sleep(
+ nanoseconds: UInt64(delay * 1_000_000_000)
+ )
+ guard !Task.isCancelled, token == entranceReleaseToken else {
+ return
+ }
+ for id in ids {
+ entranceModes[id] = nil
+ }
+ }
}
private var isSelectingMessages: Bool {
@@ -319,20 +603,54 @@ struct ChatView: View {
private var messageTimeline: some View {
ScrollViewReader { proxy in
- let timelineEntries = model.selectedTimelineEntries
GeometryReader { viewport in
ZStack(alignment: .bottomTrailing) {
ScrollView {
LazyVStack(spacing: 5) {
+ if hasCompletedInitialScroll,
+ (hasMoreLocalHistory || model.hasMoreOlderHistory),
+ !timelineEntries.isEmpty
+ {
+ Color.clear
+ .frame(height: 1)
+ .accessibilityHidden(true)
+ .onAppear {
+ historyTopTriggerVisible = true
+ guard historyTopTriggerArmed,
+ !model.isLoadingOlderHistory
+ else { return }
+ triggerOlderHistoryLoad()
+ }
+ .onDisappear {
+ historyTopTriggerVisible = false
+ historyTopTriggerArmed = true
+ historyAutoContinueCount = 0
+ }
+ if model.isLoadingOlderHistory {
+ ProgressView()
+ .controlSize(.small)
+ .padding(.vertical, 6)
+ }
+ }
if timelineEntries.isEmpty {
ContentUnavailableView(
emptyChatTitle,
systemImage: conversation.isGroup
? "person.3.fill"
- : (encryptionEnabled ? "lock.shield" : "lock.open"),
+ : (encryptionEnabled
+ ? "lock.shield" : "lock.open"),
description: Text(emptyChatDescription)
)
.padding(.top, 80)
+ .onAppear {
+ // Empty timeline has no scroll gesture, so load
+ // the first page directly when it appears.
+ guard hasMoreLocalHistory || model.hasMoreOlderHistory,
+ !model.isLoadingOlderHistory
+ else { return }
+ model
+ .loadOlderHistoryForSelectedConversation()
+ }
} else {
ForEach(timelineEntries) { entry in
let message = entry.message
@@ -343,89 +661,203 @@ struct ChatView: View {
model: model,
message: message,
isSelectionMode: isSelectingMessages,
- isSelected: selectedMessageIDs.contains(message.id),
+ isSelected: selectedMessageIDs.contains(
+ message.clientID
+ ),
onAttachmentTap: {
- Task { await model.previewAttachment(message) }
+ Task {
+ await model.previewAttachment(
+ message
+ )
+ }
},
onEdit: { startEditing(message) },
onReply: { startReplying(to: message) },
- onForward: { presentForwarding([message]) },
+ onForward: {
+ presentForwarding([message])
+ },
onRetry: {
- Task { await model.retryMediaMessage(message) }
+ Task {
+ await model.retryMediaMessage(
+ message
+ )
+ }
+ },
+ onToggleSelection: {
+ toggleMessageSelection(message)
+ },
+ onBeginSelection: {
+ beginMessageSelection(with: message)
},
- onToggleSelection: { toggleMessageSelection(message) },
- onBeginSelection: { beginMessageSelection(with: message) },
onRetract: {
- destructiveAction = .init(kind: .retract, messages: [message])
+ destructiveAction = .init(
+ kind: .retract,
+ messages: [message]
+ )
},
onDelete: {
- destructiveAction = .init(kind: .localDelete, messages: [message])
+ destructiveAction = .init(
+ kind: .localDelete,
+ messages: [message]
+ )
},
onReplyTap: { targetID in
presentReplyThread(
rootID: targetID,
- selectedReplyID: message.id
+ selectedReplyID: message
+ .clientID
)
},
onReact: { emoji in
- Task { await model.toggleReaction(emoji, on: message) }
+ Task {
+ await model.toggleReaction(
+ emoji,
+ on: message
+ )
+ }
+ },
+ onReactPicker: {
+ emojiPickerPresentation = .reaction(
+ message
+ )
}
)
- .id(message.id)
+ .modifier(
+ MessageBubbleEntrance(
+ mode: entranceModes[
+ message.clientID
+ ] ?? .settled
+ )
+ )
+ .id(message.clientID)
}
}
}
.padding(.vertical, 10)
timelineBottomSentinel
-#if os(iOS)
- ChatScrollMetricsObserver(identity: conversation.id) { isNearBottom in
- guard isNearTimelineBottom != isNearBottom else { return }
- isNearTimelineBottom = isNearBottom
- }
- .frame(height: 0)
- .accessibilityHidden(true)
-#endif
+ #if os(iOS)
+ ChatScrollMetricsObserver(
+ identity: conversation.jid
+ ) { isNearBottom in
+ guard isNearTimelineBottom != isNearBottom
+ else { return }
+ isNearTimelineBottom = isNearBottom
+ }
+ .frame(height: 0)
+ .accessibilityHidden(true)
+ #endif
}
.defaultScrollAnchor(.bottom)
-#if os(iOS)
- .scrollDismissesKeyboard(.interactively)
-#endif
+ #if os(iOS)
+ .scrollDismissesKeyboard(.interactively)
+ #endif
.coordinateSpace(name: Self.timelineCoordinateSpace)
-#if os(macOS)
- .onPreferenceChange(TimelineBottomYPreferenceKey.self) { bottomY in
- updateTimelineBottomProximity(
- bottomY: bottomY,
- viewportHeight: viewport.size.height
- )
+ .onAppear {
+ rebuildTimelineEntries()
}
-#endif
+ .onChange(of: messages) { _, newMessages in
+ rebuildTimelineEntries(from: newMessages)
+ }
+ .accessibilityIdentifier("chat-timeline")
+ #if os(macOS)
+ .onPreferenceChange(TimelineBottomYPreferenceKey.self) {
+ bottomY in
+ updateTimelineBottomProximity(
+ bottomY: bottomY,
+ viewportHeight: viewport.size.height
+ )
+ }
+ #endif
.task(id: timelineEntries.last?.id) {
await performInitialScrollAfterLayout(using: proxy)
}
.onChange(of: timelineEntries.last?.id) { oldID, newID in
guard hasCompletedInitialScroll,
- let newID,
- newID != oldID,
- isNearTimelineBottom
- || timelineEntries.last?.message.direction == .outgoing else {
+ let newID,
+ newID != oldID,
+ isNearTimelineBottom
+ || timelineEntries.last?.message.direction
+ == .outgoing
+ else {
return
}
scrollToBottom(proxy, animated: true)
}
+ .onChange(of: model.isLoadingOlderHistory) {
+ wasLoading,
+ isLoading in
+ guard wasLoading, !isLoading else { return }
+ // Restore the scroll position so the newly loaded
+ // page appears above the previously visible one. Only
+ // while the user is still at the top: a slow page must
+ // never yank the timeline away from where the user
+ // scrolled while it was loading.
+ if let anchor = historyLoadAnchorID,
+ historyTopTriggerVisible
+ {
+ Task { @MainActor in
+ await Task.yield()
+ proxy.scrollTo(anchor, anchor: .top)
+ historyLoadAnchorID = nil
+ }
+ } else {
+ historyLoadAnchorID = nil
+ }
+ // The trigger is one-shot per visibility cycle.
+ // Re-arm it whenever a load settles so the next
+ // scroll gesture can fire again even if this page
+ // inserted nothing.
+ historyTopTriggerArmed = true
+ if timelineEntries.isEmpty {
+ handleEmptyHistoryLoadCompleted()
+ } else if historyTopTriggerVisible {
+ // The page left the sentinel on screen: it either
+ // inserted no rows (reactions, duplicates) or the
+ // anchor scroll did not push it away. While the
+ // user stays at the top, continue through such
+ // pages so scroll-up never appears dead. Every
+ // page advances the server cursor, so this loop
+ // is bounded by the archive itself.
+ if timelineEntries.count
+ == historyLoadEntryCount,
+ model.hasMoreOlderHistory,
+ historyAutoContinueCount < 6
+ {
+ historyAutoContinueCount += 1
+ triggerOlderHistoryLoad()
+ } else {
+ historyAutoContinueCount = 0
+ }
+ }
+ }
.onChange(of: timelineEntries.count) { _, _ in
selectedMessageIDs.formIntersection(
Set(timelineEntries.map(\.id))
)
+ guard historyLoadAnchorID != nil,
+ !model.isLoadingOlderHistory,
+ historyTopTriggerVisible,
+ let anchor = historyLoadAnchorID
+ else { return }
+ Task { @MainActor in
+ await Task.yield()
+ proxy.scrollTo(anchor, anchor: .top)
+ historyLoadAnchorID = nil
+ historyTopTriggerArmed = true
+ }
}
if hasCompletedInitialScroll,
- !timelineEntries.isEmpty,
- !isNearTimelineBottom {
+ !timelineEntries.isEmpty,
+ !isNearTimelineBottom
+ {
jumpToLatestButton(using: proxy)
.padding(.trailing, 14)
.padding(.bottom, 12)
- .transition(.scale(scale: 0.82).combined(with: .opacity))
+ .transition(
+ .scale(scale: 0.82).combined(with: .opacity)
+ )
.zIndex(10)
}
}
@@ -439,27 +871,27 @@ struct ChatView: View {
@ViewBuilder
private var timelineBottomSentinel: some View {
-#if os(iOS)
- Color.clear
- .frame(height: 1)
- .accessibilityHidden(true)
- .id(Self.bottomAnchorID)
-#else
- Color.clear
- .frame(height: 1)
- .background {
- GeometryReader { geometry in
- Color.clear.preference(
- key: TimelineBottomYPreferenceKey.self,
- value: geometry.frame(
- in: .named(Self.timelineCoordinateSpace)
- ).maxY
- )
+ #if os(iOS)
+ Color.clear
+ .frame(height: 1)
+ .accessibilityHidden(true)
+ .id(Self.bottomAnchorID)
+ #else
+ Color.clear
+ .frame(height: 1)
+ .background {
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: TimelineBottomYPreferenceKey.self,
+ value: geometry.frame(
+ in: .named(Self.timelineCoordinateSpace)
+ ).maxY
+ )
+ }
}
- }
- .accessibilityHidden(true)
- .id(Self.bottomAnchorID)
-#endif
+ .accessibilityHidden(true)
+ .id(Self.bottomAnchorID)
+ #endif
}
private var selectionActionBar: some View {
@@ -495,7 +927,8 @@ struct ChatView: View {
}
if !selectedTimelineMessages.isEmpty,
- selectedTimelineMessages.allSatisfy(\.canBeRetracted) {
+ selectedTimelineMessages.allSatisfy(\.canBeRetracted)
+ {
Button(role: .destructive) {
destructiveAction = .init(
kind: .retract,
@@ -521,12 +954,6 @@ struct ChatView: View {
private var composer: some View {
VStack(spacing: 8) {
- if let editingMessage {
- editingBanner(editingMessage)
- } else if let replyingToMessage {
- replyBanner(replyingToMessage)
- }
-
HStack(alignment: .bottom, spacing: 8) {
Group {
if isCapturePresentationVisible {
@@ -548,46 +975,49 @@ struct ChatView: View {
}
.padding(.top, 6)
.background(Color.clear)
- .animation(.easeInOut(duration: 0.18), value: isCapturePresentationVisible)
+ .animation(
+ .easeInOut(duration: 0.18),
+ value: isCapturePresentationVisible
+ )
}
private var messageInput: some View {
- HStack(alignment: .bottom, spacing: 8) {
- TextField(
- editingMessageID == nil ? "Сообщение" : "Изменить сообщение",
- text: $draft,
- axis: .vertical
- )
- .focused($isComposerFocused)
- .lineLimit(1...6)
- .textFieldStyle(.plain)
- .submitLabel(.send)
- .onSubmit { sendDraft() }
+ VStack {
+ if let editingMessage {
+ editingBanner(editingMessage)
+ } else if let replyingToMessage {
+ replyBanner(replyingToMessage)
+ }
+ HStack(alignment: .center, spacing: 8) {
+ TextField(
+ editingMessageID == nil
+ ? "Сообщение" : "Изменить сообщение",
+ text: $draft,
+ axis: .vertical
+ )
+ .focused($isComposerFocused)
+ .lineLimit(1...6)
+ .textFieldStyle(.plain)
+ .submitLabel(.send)
+ .onSubmit { sendDraft() }
- Image(systemName: "face.smiling")
- .font(.system(size: 20, weight: .regular))
- .foregroundStyle(.secondary)
- .padding(.bottom, 1)
- .accessibilityHidden(true)
+ Button {
+ emojiPickerPresentation = .composer
+ } label: {
+ Image(systemName: "face.smiling")
+ .font(.system(size: 20, weight: .regular))
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 1)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Эмодзи")
+ }
}
.padding(.leading, 14)
.padding(.trailing, 12)
.padding(.vertical, 9)
.frame(minHeight: 42)
- .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 21, style: .continuous)
- .stroke(
- LinearGradient(
- colors: [Color.white.opacity(0.3), Color.secondary.opacity(0.18)],
- startPoint: .top,
- endPoint: .bottom
- ),
- lineWidth: 0.75
- )
- )
- .shadow(color: .black.opacity(0.075), radius: 8, y: 2)
- .contentShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
+ .glassEffect(in: .rect(cornerRadius: 21))
}
@ViewBuilder
@@ -596,18 +1026,18 @@ struct ChatView: View {
Button(action: sendDraft) {
Image(systemName: "arrow.up")
.font(.system(size: 18, weight: .bold))
- .foregroundStyle(canUseComposer ? Color.accentColor : Color.secondary)
- .frame(width: 42, height: 42)
- .background(.ultraThinMaterial, in: Circle())
- .overlay(
- Circle()
- .stroke(Color.secondary.opacity(0.2), lineWidth: 0.75)
+ .foregroundStyle(
+ canUseComposer ? Color.accentColor : Color.secondary
)
- .shadow(color: .black.opacity(0.075), radius: 8, y: 2)
+ .frame(width: 42, height: 42)
+ // .background(.ultraThinMaterial, in: Circle())
}
.buttonStyle(.plain)
.disabled(!canUseComposer)
- .accessibilityLabel(editingMessageID == nil ? "Отправить" : "Сохранить исправление")
+ .accessibilityLabel(
+ editingMessageID == nil ? "Отправить" : "Сохранить исправление"
+ )
+ .glassEffect()
} else {
TelegramRecordButton(
mode: .voice,
@@ -626,7 +1056,16 @@ struct ChatView: View {
.overlay(alignment: .top) {
if isCapturePresentationVisible, !captureIsLocked {
recordingLockIndicator
- .offset(y: -74 + min(12, max(-14, captureDragTranslation.height * 0.12)))
+ .offset(
+ y: -74
+ + min(
+ 12,
+ max(
+ -14,
+ captureDragTranslation.height * 0.12
+ )
+ )
+ )
.transition(.scale.combined(with: .opacity))
}
}
@@ -635,21 +1074,26 @@ struct ChatView: View {
private var attachmentButton: some View {
Menu {
-#if os(iOS)
- Button {
- presentCamera()
- } label: {
- Label("Камера", systemImage: "camera.fill")
- }
- .disabled(!SystemPhotoCameraView.isAvailable)
-#endif
+ #if os(iOS)
+ Button {
+ presentCamera()
+ } label: {
+ Label("Камера", systemImage: "camera.fill")
+ }
+ .disabled(!SystemPhotoCameraView.isAvailable)
+ #endif
Button {
dismissKeyboard()
showingMediaPicker = true
} label: {
- Label("Фото или видео (несколько)", systemImage: "photo.on.rectangle")
+ Label(
+ "Фото или видео (несколько)",
+ systemImage: "photo.on.rectangle"
+ )
}
- Button { presentFileImporter(.audio) } label: {
+ Button {
+ presentFileImporter(.audio)
+ } label: {
Label("Музыка", systemImage: "music.note")
}
Button {
@@ -665,17 +1109,13 @@ struct ChatView: View {
Label("Отправить геопозицию", systemImage: "location.fill")
}
Divider()
- Button { presentFileImporter(.files) } label: {
+ Button {
+ presentFileImporter(.files)
+ } label: {
Label("Файлы (несколько)", systemImage: "doc.on.doc")
}
} label: {
ZStack {
- Circle()
- .fill(.ultraThinMaterial)
- .overlay(
- Circle()
- .stroke(Color.secondary.opacity(0.18), lineWidth: 0.75)
- )
if model.isSendingAttachment || isPreparingAttachments {
ProgressView()
.controlSize(.small)
@@ -686,28 +1126,31 @@ struct ChatView: View {
}
}
.frame(width: 42, height: 42)
- .shadow(color: .black.opacity(0.075), radius: 8, y: 2)
.contentShape(Circle())
+ .glassEffect()
}
.buttonStyle(.plain)
.accessibilityLabel("Добавить вложение")
- .help("Открыть камеру или добавить фото, видео, файлы, музыку и геопозицию")
+ .help(
+ "Открыть камеру или добавить фото, видео, файлы, музыку и геопозицию"
+ )
}
private var editingMessage: ChatMessage? {
guard let editingMessageID else { return nil }
- return model.selectedMessages.first { $0.id == editingMessageID }
+ return model.selectedMessages.first { $0.clientID == editingMessageID }
}
private var replyingToMessage: ChatMessage? {
guard let replyingToMessageID else { return nil }
return model.selectedMessages.first {
- $0.id == replyingToMessageID && $0.canBeRepliedTo
+ $0.clientID == replyingToMessageID && $0.canBeRepliedTo
}
}
private var requiresOMEMO: Bool {
- editingMessage?.security == .omemo || (editingMessageID == nil && encryptionEnabled)
+ editingMessage?.security == .omemo
+ || (editingMessageID == nil && encryptionEnabled)
}
private var encryptionEnabled: Bool {
@@ -716,28 +1159,17 @@ struct ChatView: View {
private var encryptionIcon: String {
if !encryptionEnabled { return "lock.open" }
- return model.isOMEMOReady ? "lock.fill" : "lock.trianglebadge.exclamationmark"
+ return model.isOMEMOReady
+ ? "lock.fill" : "lock.trianglebadge.exclamationmark"
}
private var liveConversation: Conversation {
- model.conversations.first(where: { $0.id == conversation.id }) ?? conversation
+ model.conversations.first(where: { $0.jid == conversation.jid })
+ ?? conversation
}
private var chatNavigationTitle: some View {
- VStack(spacing: 0) {
- Text(liveConversation.displayName)
- .font(.headline)
- .lineLimit(1)
- if let typingText = model.typingText(for: liveConversation) {
- Text(typingText)
- .font(.caption2.weight(.medium))
- .foregroundStyle(.tint)
- .lineLimit(1)
- .transition(.opacity)
- }
- }
- .animation(.easeInOut(duration: 0.16), value: model.typingText(for: liveConversation))
- .accessibilityElement(children: .combine)
+ ChatNavigationTitle(model: model, conversation: conversation)
}
private var canSendToConversation: Bool {
@@ -746,7 +1178,8 @@ struct ChatView: View {
private var emptyChatTitle: String {
if conversation.isGroup { return "Групповая комната" }
- return encryptionEnabled ? "Новый защищённый чат" : "Новый чат без шифрования"
+ return encryptionEnabled
+ ? "Новый защищённый чат" : "Новый чат без шифрования"
}
private var emptyChatDescription: String {
@@ -762,11 +1195,17 @@ struct ChatView: View {
private var groupJoinBanner: some View {
HStack(spacing: 10) {
- Image(systemName: liveConversation.invitedBy == nil ? "person.3" : "envelope.badge")
- .foregroundStyle(.tint)
+ Image(
+ systemName: liveConversation.invitedBy == nil
+ ? "person.3" : "envelope.badge"
+ )
+ .foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
- Text(liveConversation.invitedBy == nil ? "Комната не подключена" : "Вас пригласили в комнату")
- .font(.subheadline.weight(.semibold))
+ Text(
+ liveConversation.invitedBy == nil
+ ? "Комната не подключена" : "Вас пригласили в комнату"
+ )
+ .font(.subheadline.weight(.semibold))
Text(liveConversation.invitedBy ?? conversation.jid)
.font(.caption)
.foregroundStyle(.secondary)
@@ -813,7 +1252,9 @@ struct ChatView: View {
}
private var recordingCancelHintOpacity: Double {
- ComposerRecordingGesturePolicy.cancelHintOpacity(for: captureDragTranslation)
+ ComposerRecordingGesturePolicy.cancelHintOpacity(
+ for: captureDragTranslation
+ )
}
private var recordingStatusStrip: some View {
@@ -847,11 +1288,7 @@ struct ChatView: View {
}
.padding(.horizontal, 13)
.frame(minHeight: 42)
- .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 21, style: .continuous)
- .stroke(Color.secondary.opacity(0.2), lineWidth: 0.75)
- )
+ .glassEffect()
}
private var recordingLockIndicator: some View {
@@ -863,8 +1300,7 @@ struct ChatView: View {
}
.foregroundStyle(.primary)
.frame(width: 38, height: 55)
- .background(.ultraThinMaterial, in: Capsule())
- .overlay(Capsule().stroke(Color.secondary.opacity(0.22), lineWidth: 0.75))
+ .glassEffect(in: Capsule())
.accessibilityLabel("Проведите вверх, чтобы зафиксировать запись")
}
@@ -876,9 +1312,10 @@ struct ChatView: View {
private func beginCaptureGesture() {
guard canUseComposer,
- draftIsEmpty,
- editingMessageID == nil,
- !isCapturePresentationVisible else { return }
+ draftIsEmpty,
+ editingMessageID == nil,
+ !isCapturePresentationVisible
+ else { return }
dismissKeyboard()
model.audioPlayback.stop()
@@ -896,8 +1333,9 @@ struct ChatView: View {
do {
try await audioRecorder.start()
guard !Task.isCancelled,
- captureAttemptID == attemptID,
- captureGestureIsActive || captureIsLocked else {
+ captureAttemptID == attemptID,
+ captureGestureIsActive || captureIsLocked
+ else {
audioRecorder.cancel()
return
}
@@ -1008,30 +1446,33 @@ struct ChatView: View {
private func dismissKeyboard() {
isComposerFocused = false
-#if os(iOS)
- UIApplication.shared.sendAction(
- #selector(UIResponder.resignFirstResponder),
- to: nil,
- from: nil,
- for: nil
- )
-#endif
+ #if os(iOS)
+ UIApplication.shared.sendAction(
+ #selector(UIResponder.resignFirstResponder),
+ to: nil,
+ from: nil,
+ for: nil
+ )
+ #endif
}
private func impactFeedback() {
-#if os(iOS)
- UIImpactFeedbackGenerator(style: .medium).impactOccurred()
-#endif
+ #if os(iOS)
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ #endif
}
private func sendDraft() {
let outgoing = draft
guard canSendToConversation,
- !outgoing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
+ !outgoing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ else { return }
draft = ""
if let editingMessageID {
self.editingMessageID = nil
- Task { await model.editMessage(id: editingMessageID, newBody: outgoing) }
+ Task {
+ await model.editMessage(id: editingMessageID, newBody: outgoing)
+ }
} else {
let replyTarget = replyingToMessage
replyingToMessageID = nil
@@ -1045,11 +1486,12 @@ struct ChatView: View {
}
}
+ @discardableResult
private func stageImportedFiles(
_ urls: [URL],
preferredKind: ChatMessage.Kind? = nil
- ) async {
- guard !urls.isEmpty else { return }
+ ) async -> Bool {
+ guard !urls.isEmpty else { return false }
isPreparingAttachments = true
defer { isPreparingAttachments = false }
let prepared = await model.prepareAttachmentDrafts(
@@ -1057,10 +1499,11 @@ struct ChatView: View {
preferredKind: preferredKind,
for: conversation.jid
)
- guard !prepared.isEmpty else { return }
+ guard !prepared.isEmpty else { return false }
model.discardAttachmentDrafts(attachmentDrafts)
attachmentDrafts = prepared
presentAttachmentPreviewAfterPickerDismissal()
+ return true
}
private func stageCapturedMedia(_ media: CapturedCameraMedia) async {
@@ -1069,27 +1512,72 @@ struct ChatView: View {
photoCameraIsPreparingResult = false
releaseArchiveSyncAfterCapture()
}
- await stageImportedFiles([media.url], preferredKind: media.kind)
+ let logger = Logger(subsystem: "Luma", category: "video-preview")
+ await waitForCameraFileSettling(at: media.url, logger: logger)
+ let staged = await stageImportedFiles(
+ [media.url],
+ preferredKind: media.kind
+ )
+ if staged {
+ if let draft = attachmentDrafts.last {
+ logger.info(
+ "camera draft staged: kind=\(draft.kind.rawValue) mime=\(draft.mimeType) size=\(draft.byteCount) duration=\(String(describing: draft.duration)) thumbnailBytes=\(draft.thumbnailData?.count ?? 0)"
+ )
+ }
+ } else {
+ logger.error("camera staging produced no drafts")
+ if model.errorMessage == nil {
+ model.errorMessage =
+ "Камера вернула файл, который не удалось подготовить к отправке."
+ }
+ }
+ }
+
+ /// The picker can hand over a movie whose final bytes are still being
+ /// flushed. Wait until the file size stops changing so staging never
+ /// copies a truncated file.
+ private func waitForCameraFileSettling(at url: URL, logger: Logger) async {
+ guard
+ let initial = try? url.resourceValues(forKeys: [.fileSizeKey])
+ .fileSize
+ else {
+ return
+ }
+ var previous = initial
+ for _ in 0..<10 {
+ try? await Task.sleep(nanoseconds: 250_000_000)
+ guard
+ let current = try? url.resourceValues(forKeys: [.fileSizeKey])
+ .fileSize
+ else {
+ return
+ }
+ if current == previous {
+ return
+ }
+ previous = current
+ }
+ logger.warning("camera file size kept changing; staging anyway")
}
private func presentCamera() {
-#if os(iOS)
- guard SystemPhotoCameraView.isAvailable else {
- model.errorMessage = "Камера недоступна на этом устройстве."
- return
- }
- dismissKeyboard()
- model.audioPlayback.stop()
- photoCameraIsPreparingResult = false
- if !captureSuspendsArchiveSync {
- captureSuspendsArchiveSync = true
- model.setVideoNoteCaptureActive(true)
- }
- Task { @MainActor in
- await Task.yield()
- showingPhotoCamera = true
- }
-#endif
+ #if os(iOS)
+ guard SystemPhotoCameraView.isAvailable else {
+ model.errorMessage = "Камера недоступна на этом устройстве."
+ return
+ }
+ dismissKeyboard()
+ model.audioPlayback.stop()
+ photoCameraIsPreparingResult = false
+ if !captureSuspendsArchiveSync {
+ captureSuspendsArchiveSync = true
+ model.setVideoNoteCaptureActive(true)
+ }
+ Task { @MainActor in
+ await Task.yield()
+ showingPhotoCamera = true
+ }
+ #endif
}
private func presentVideoNoteRecorder() {
@@ -1141,8 +1629,9 @@ struct ChatView: View {
}
private func presentReplyThread(rootID: String, selectedReplyID: String) {
- guard model.message(withID: rootID, in: conversation.id) != nil else {
- model.errorMessage = "Исходное сообщение ещё не загружено в локальную историю."
+ guard model.message(withID: rootID, in: conversation.jid) != nil else {
+ model.errorMessage =
+ "Исходное сообщение ещё не загружено в локальную историю."
return
}
withAnimation(.easeOut(duration: 0.2)) {
@@ -1163,7 +1652,7 @@ struct ChatView: View {
for root: ChatMessage,
selectedReplyID: String
) -> [ChatMessage] {
- var sourceIdentifiers: Set = [root.id]
+ var sourceIdentifiers: Set = [root.clientID]
if let stanzaID = root.stanzaID { sourceIdentifiers.insert(stanzaID) }
if let replyIdentifier = root.replyIdentifier {
sourceIdentifiers.insert(replyIdentifier)
@@ -1173,12 +1662,18 @@ struct ChatView: View {
guard let replyToID = candidate.replyToID else { return false }
return sourceIdentifiers.contains(replyToID)
}
- if !replies.contains(where: { $0.id == selectedReplyID }),
- let selected = model.message(withID: selectedReplyID, in: conversation.id) {
+ if !replies.contains(where: { $0.clientID == selectedReplyID }),
+ let selected = model.message(
+ withID: selectedReplyID,
+ in: conversation.jid
+ )
+ {
replies.append(selected)
}
return replies.sorted { lhs, rhs in
- if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
+ if lhs.timestamp == rhs.timestamp {
+ return lhs.clientID < rhs.clientID
+ }
return lhs.timestamp < rhs.timestamp
}
}
@@ -1189,7 +1684,6 @@ struct ChatView: View {
var sourceURLs: [URL] = []
var failedItemCount = 0
defer {
- pickedMediaItems = []
isPreparingAttachments = false
sourceURLs.forEach(discardPickedMediaFile)
}
@@ -1211,7 +1705,8 @@ struct ChatView: View {
}
guard !sourceURLs.isEmpty else {
- model.errorMessage = MediaPickerError.cannotReadSelection.errorDescription
+ model.errorMessage =
+ MediaPickerError.cannotReadSelection.errorDescription
return
}
failedItemCount += max(0, sourceURLs.count - prepared.count)
@@ -1220,7 +1715,8 @@ struct ChatView: View {
attachmentDrafts = prepared
presentAttachmentPreviewAfterPickerDismissal()
if failedItemCount > 0 {
- model.errorMessage = "Не удалось подготовить \(failedItemCount) из \(items.prefix(20).count) выбранных фото или видео. Остальные готовы к отправке."
+ model.errorMessage =
+ "Не удалось подготовить \(failedItemCount) из \(items.prefix(20).count) выбранных фото или видео. Остальные готовы к отправке."
}
}
@@ -1246,13 +1742,15 @@ struct ChatView: View {
for _ in 0..<120 {
guard !Task.isCancelled,
- attachmentPreviewPresentationPending,
- !attachmentDrafts.isEmpty else { return }
+ attachmentPreviewPresentationPending,
+ !attachmentDrafts.isEmpty
+ else { return }
if !showingMediaPicker,
- !showingPhotoCamera,
- !showingFileImporter,
- !isPreparingAttachments,
- !showingAttachmentPreview {
+ !showingPhotoCamera,
+ !showingFileImporter,
+ !isPreparingAttachments,
+ !showingAttachmentPreview
+ {
attachmentPreviewPresentationPending = false
showingAttachmentPreview = true
attachmentPreviewPresentationTask = nil
@@ -1268,7 +1766,9 @@ struct ChatView: View {
}
}
- private func loadPickedMediaFile(_ item: PhotosPickerItem) async throws -> URL {
+ private func loadPickedMediaFile(_ item: PhotosPickerItem) async throws
+ -> URL
+ {
let supportsImage = item.supportedContentTypes.contains {
$0.conforms(to: .image)
}
@@ -1299,12 +1799,16 @@ struct ChatView: View {
}
private func loadPickedPhoto(_ item: PhotosPickerItem) async throws -> URL {
- let fileRepresentation = try? await item.loadTransferable(type: PickedPhoto.self)
+ let fileRepresentation = try? await item.loadTransferable(
+ type: PickedPhoto.self
+ )
if let photo = fileRepresentation {
return photo.url
}
- let dataRepresentation = try await item.loadTransferable(type: Data.self)
+ let dataRepresentation = try await item.loadTransferable(
+ type: Data.self
+ )
guard let data = dataRepresentation, !data.isEmpty else {
throw MediaPickerError.cannotReadSelection
}
@@ -1314,7 +1818,9 @@ struct ChatView: View {
}
private func loadPickedVideo(_ item: PhotosPickerItem) async throws -> URL {
- guard let video = try await item.loadTransferable(type: PickedVideo.self) else {
+ guard
+ let video = try await item.loadTransferable(type: PickedVideo.self)
+ else {
throw MediaPickerError.cannotReadSelection
}
return video.url
@@ -1336,24 +1842,36 @@ struct ChatView: View {
guard message.canBeEdited else { return }
cancelComposerRecording(feedback: false)
replyingToMessageID = nil
- editingMessageID = message.id
+ editingMessageID = message.clientID
draft = message.body
isComposerFocused = true
}
+ private func handleEmojiSelection(
+ _ emoji: String,
+ for presentation: EmojiPickerPresentation
+ ) {
+ switch presentation {
+ case .reaction(let message):
+ Task { await model.toggleReaction(emoji, on: message) }
+ case .composer:
+ draft.append(emoji)
+ }
+ }
+
private func beginMessageSelection(with message: ChatMessage) {
cancelComposerRecording(feedback: false)
dismissKeyboard()
if editingMessageID != nil { cancelEditing() }
cancelReplying()
- selectedMessageIDs.insert(message.id)
+ selectedMessageIDs.insert(message.clientID)
}
private func toggleMessageSelection(_ message: ChatMessage) {
- if selectedMessageIDs.contains(message.id) {
- selectedMessageIDs.remove(message.id)
+ if selectedMessageIDs.contains(message.clientID) {
+ selectedMessageIDs.remove(message.clientID)
} else {
- selectedMessageIDs.insert(message.id)
+ selectedMessageIDs.insert(message.clientID)
}
}
@@ -1374,7 +1892,7 @@ struct ChatView: View {
editingMessageID = nil
draft = ""
}
- replyingToMessageID = message.id
+ replyingToMessageID = message.clientID
isComposerFocused = true
}
@@ -1392,8 +1910,6 @@ struct ChatView: View {
Capsule()
.fill(Color.accentColor)
.frame(width: 3, height: 34)
- Image(systemName: "pencil")
- .foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) {
Text("Редактирование")
.font(.caption.weight(.semibold))
@@ -1405,45 +1921,49 @@ struct ChatView: View {
}
Spacer()
Button(action: cancelEditing) {
- Image(systemName: "xmark.circle.fill")
+ Image(systemName: "xmark")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Отменить редактирование")
}
- .padding(.horizontal, 14)
- .padding(.top, 8)
}
+ /// Telegram-style reply plate: a rounded card above the composer with
+ /// the reply header, a close button and the quoted message preview.
private func replyBanner(_ message: ChatMessage) -> some View {
- HStack(spacing: 10) {
+ HStack(alignment: .center) {
Capsule()
.fill(Color.accentColor)
- .frame(width: 3, height: 36)
- VStack(alignment: .leading, spacing: 1) {
- Text("Ответ: \(message.senderDisplayName ?? model.displayName(for: message.senderJID))")
+ .frame(width: 3, height: 34)
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 7) {
+ Text(
+ "Ответ: \(message.senderDisplayName ?? model.displayName(for: message.senderJID))"
+ )
.font(.caption.weight(.semibold))
.foregroundStyle(.tint)
+ .lineLimit(1)
+ }
Text(message.quotePreview)
.font(.caption)
- .foregroundStyle(.secondary)
+ .foregroundStyle(.primary.opacity(0.8))
.lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
Button(action: cancelReplying) {
- Image(systemName: "xmark.circle.fill")
+ Image(systemName: "xmark")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Отменить ответ")
}
- .padding(.horizontal, 14)
- .padding(.top, 8)
- .padding(.bottom, 3)
+ .accessibilityIdentifier("reply-banner")
}
private func perform(_ action: MessageDestructiveAction) {
- let ids = Set(action.messages.map(\.id))
+ let ids = Set(action.messages.map(\.clientID))
if let editingMessageID, ids.contains(editingMessageID) {
cancelEditing()
}
@@ -1453,11 +1973,11 @@ struct ChatView: View {
selectedMessageIDs.subtract(ids)
switch action.kind {
case .localDelete:
- model.deleteMessagesLocally(ids: ids, in: conversation.id)
+ model.deleteMessagesLocally(ids: ids, in: conversation.jid)
case .retract:
Task {
for message in action.messages where message.canBeRetracted {
- await model.retractMessage(id: message.id)
+ await model.retractMessage(id: message.clientID)
}
}
}
@@ -1481,15 +2001,10 @@ struct ChatView: View {
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: 46, height: 46)
- .background(.ultraThinMaterial, in: Circle())
- .overlay {
- Circle()
- .stroke(Color.accentColor.opacity(0.42), lineWidth: 1)
- }
- .shadow(color: .black.opacity(0.16), radius: 10, y: 4)
}
.buttonStyle(.plain)
.contentShape(Circle())
+ .glassEffect()
.accessibilityLabel("Перейти к последнему сообщению")
.help("Перейти в конец истории")
}
@@ -1504,9 +2019,60 @@ struct ChatView: View {
}
}
- private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy) async {
- guard !hasCompletedInitialScroll,
- !model.selectedMessages.isEmpty else { return }
+ /// Starts one interactive older-history page load from the top sentinel.
+ /// Remembers the first visible entry so the viewport can be restored when
+ /// the page arrives.
+ private func triggerOlderHistoryLoad() {
+ historyTopTriggerArmed = false
+ historyLoadAnchorID = timelineEntries.first?.id
+ historyLoadEntryCount = timelineEntries.count
+ if hasMoreLocalHistory {
+ visibleLocalMessageCount = min(
+ messages.count,
+ visibleLocalMessageCount + Self.localHistoryPageSize
+ )
+ localHistoryExhausted = visibleLocalMessageCount >= messages.count
+ rebuildTimelineEntries(from: messages)
+ historyTopTriggerArmed = true
+ return
+ }
+ model.loadOlderHistoryForSelectedConversation()
+ }
+
+ /// A finished load left the timeline empty. Retry a bounded number of
+ /// times with a short delay: the first page of an empty chat can fail
+ /// transiently (timeout, reconnect), and an empty timeline has no scroll
+ /// gesture that could retry it manually.
+ private func handleEmptyHistoryLoadCompleted() {
+ guard model.hasMoreOlderHistory,
+ !model.isLoadingOlderHistory,
+ emptyHistoryRetryCount < 3
+ else { return }
+ emptyHistoryRetryCount += 1
+ Task { @MainActor [weak model] in
+ try? await Task.sleep(nanoseconds: 1_500_000_000)
+ guard !Task.isCancelled,
+ timelineEntries.isEmpty,
+ model?.hasMoreOlderHistory == true,
+ model?.isLoadingOlderHistory == false
+ else { return }
+ model?.loadOlderHistoryForSelectedConversation()
+ }
+ }
+
+ private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy)
+ async
+ {
+ guard !hasCompletedInitialScroll else { return }
+ guard !model.selectedMessages.isEmpty else {
+ // Empty conversation: there is nothing to scroll to, so the
+ // scroll-to-top gesture can never fire. Load the first page of
+ // history directly so a newly-created or emptied chat still fills.
+ if model.hasMoreOlderHistory, !model.isLoadingOlderHistory {
+ model.loadOlderHistoryForSelectedConversation()
+ }
+ return
+ }
// ScrollViewReader can receive its first command before a long
// LazyVStack has published the bottom anchor. Yield once for layout,
@@ -1549,144 +2115,151 @@ private struct TimelineBottomYPreferenceKey: PreferenceKey {
}
#if os(iOS)
-/// Reads the real UIScrollView offset without replacing its delegate or
-/// installing another pan recognizer. SwiftUI geometry preferences can stop
-/// updating after a programmatic ScrollViewReader jump on some iOS releases.
-private struct ChatScrollMetricsObserver: UIViewRepresentable {
- let identity: String
- let onNearBottomChange: (Bool) -> Void
+ /// Reads the real UIScrollView offset without replacing its delegate or
+ /// installing another pan recognizer. SwiftUI geometry preferences can stop
+ /// updating after a programmatic ScrollViewReader jump on some iOS releases.
+ private struct ChatScrollMetricsObserver: UIViewRepresentable {
+ let identity: String
+ let onNearBottomChange: (Bool) -> Void
- init(identity: String, onNearBottomChange: @escaping (Bool) -> Void) {
- self.identity = identity
- self.onNearBottomChange = onNearBottomChange
- }
-
- func makeUIView(context: Context) -> ObserverView {
- let view = ObserverView(frame: .zero)
- view.identity = identity
- view.onNearBottomChange = onNearBottomChange
- return view
- }
-
- func updateUIView(_ uiView: ObserverView, context: Context) {
- uiView.onNearBottomChange = onNearBottomChange
- if uiView.identity != identity {
- uiView.identity = identity
- uiView.resetReportedState()
- }
- uiView.attachToAncestorScrollViewIfNeeded()
- }
-
- static func dismantleUIView(_ uiView: ObserverView, coordinator: ()) {
- uiView.detach()
- }
-
- final class ObserverView: UIView {
- var identity = ""
- var onNearBottomChange: ((Bool) -> Void)?
-
- private weak var observedScrollView: UIScrollView?
- private var observations: [NSKeyValueObservation] = []
- private var lastNearBottom: Bool?
- private var attachmentScheduled = false
- private var metricsReportScheduled = false
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- isUserInteractionEnabled = false
- backgroundColor = .clear
+ init(identity: String, onNearBottomChange: @escaping (Bool) -> Void) {
+ self.identity = identity
+ self.onNearBottomChange = onNearBottomChange
}
- required init?(coder: NSCoder) {
- nil
+ func makeUIView(context: Context) -> ObserverView {
+ let view = ObserverView(frame: .zero)
+ view.identity = identity
+ view.onNearBottomChange = onNearBottomChange
+ return view
}
- override func didMoveToWindow() {
- super.didMoveToWindow()
- attachToAncestorScrollViewIfNeeded()
+ func updateUIView(_ uiView: ObserverView, context: Context) {
+ uiView.onNearBottomChange = onNearBottomChange
+ if uiView.identity != identity {
+ uiView.identity = identity
+ uiView.resetReportedState()
+ }
+ uiView.attachToAncestorScrollViewIfNeeded()
}
- override func didMoveToSuperview() {
- super.didMoveToSuperview()
- attachToAncestorScrollViewIfNeeded()
+ static func dismantleUIView(_ uiView: ObserverView, coordinator: ()) {
+ uiView.detach()
}
- func attachToAncestorScrollViewIfNeeded() {
- guard window != nil,
- !attachmentScheduled else { return }
- attachmentScheduled = true
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- self.attachmentScheduled = false
- var ancestor = self.superview
- while let view = ancestor, !(view is UIScrollView) {
- ancestor = view.superview
+ final class ObserverView: UIView {
+ var identity = ""
+ var onNearBottomChange: ((Bool) -> Void)?
+
+ private weak var observedScrollView: UIScrollView?
+ private var observations: [NSKeyValueObservation] = []
+ private var lastNearBottom: Bool?
+ private var attachmentScheduled = false
+ private var metricsReportScheduled = false
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ isUserInteractionEnabled = false
+ backgroundColor = .clear
+ }
+
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ override func didMoveToWindow() {
+ super.didMoveToWindow()
+ attachToAncestorScrollViewIfNeeded()
+ }
+
+ override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ attachToAncestorScrollViewIfNeeded()
+ }
+
+ func attachToAncestorScrollViewIfNeeded() {
+ guard window != nil,
+ !attachmentScheduled
+ else { return }
+ attachmentScheduled = true
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ self.attachmentScheduled = false
+ var ancestor = self.superview
+ while let view = ancestor, !(view is UIScrollView) {
+ ancestor = view.superview
+ }
+ guard let scrollView = ancestor as? UIScrollView else {
+ self.detach()
+ return
+ }
+ self.observe(scrollView)
}
- guard let scrollView = ancestor as? UIScrollView else {
- self.detach()
- return
+ }
+
+ func resetReportedState() {
+ lastNearBottom = nil
+ scheduleMetricsReport()
+ }
+
+ func detach() {
+ observations.forEach { $0.invalidate() }
+ observations.removeAll(keepingCapacity: false)
+ observedScrollView = nil
+ metricsReportScheduled = false
+ }
+
+ private func observe(_ scrollView: UIScrollView) {
+ guard observedScrollView !== scrollView else { return }
+ detach()
+ observedScrollView = scrollView
+ observations = [
+ scrollView.observe(
+ \.contentOffset,
+ options: [.initial, .new]
+ ) {
+ [weak self] _, _ in
+ self?.scheduleMetricsReport()
+ },
+ scrollView.observe(\.contentSize, options: [.new]) {
+ [weak self] _, _ in
+ self?.scheduleMetricsReport()
+ },
+ scrollView.observe(\.bounds, options: [.new]) {
+ [weak self] _, _ in
+ self?.scheduleMetricsReport()
+ },
+ ]
+ }
+
+ private func scheduleMetricsReport() {
+ guard !metricsReportScheduled else { return }
+ metricsReportScheduled = true
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ self.metricsReportScheduled = false
+ self.reportMetricsNow()
}
- self.observe(scrollView)
+ }
+
+ private func reportMetricsNow() {
+ guard let scrollView = observedScrollView else { return }
+ let maximumOffsetY = max(
+ -scrollView.adjustedContentInset.top,
+ scrollView.contentSize.height
+ - scrollView.bounds.height
+ + scrollView.adjustedContentInset.bottom
+ )
+ let distance = maximumOffsetY - scrollView.contentOffset.y
+ let isNearBottom = ChatScrollPositionPolicy.isNearBottom(
+ distanceFromBottom: distance
+ )
+ guard lastNearBottom != isNearBottom else { return }
+ lastNearBottom = isNearBottom
+ onNearBottomChange?(isNearBottom)
}
}
-
- func resetReportedState() {
- lastNearBottom = nil
- scheduleMetricsReport()
- }
-
- func detach() {
- observations.forEach { $0.invalidate() }
- observations.removeAll(keepingCapacity: false)
- observedScrollView = nil
- metricsReportScheduled = false
- }
-
- private func observe(_ scrollView: UIScrollView) {
- guard observedScrollView !== scrollView else { return }
- detach()
- observedScrollView = scrollView
- observations = [
- scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] _, _ in
- self?.scheduleMetricsReport()
- },
- scrollView.observe(\.contentSize, options: [.new]) { [weak self] _, _ in
- self?.scheduleMetricsReport()
- },
- scrollView.observe(\.bounds, options: [.new]) { [weak self] _, _ in
- self?.scheduleMetricsReport()
- }
- ]
- }
-
- private func scheduleMetricsReport() {
- guard !metricsReportScheduled else { return }
- metricsReportScheduled = true
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- self.metricsReportScheduled = false
- self.reportMetricsNow()
- }
- }
-
- private func reportMetricsNow() {
- guard let scrollView = observedScrollView else { return }
- let maximumOffsetY = max(
- -scrollView.adjustedContentInset.top,
- scrollView.contentSize.height
- - scrollView.bounds.height
- + scrollView.adjustedContentInset.bottom
- )
- let distance = maximumOffsetY - scrollView.contentOffset.y
- let isNearBottom = ChatScrollPositionPolicy.isNearBottom(
- distanceFromBottom: distance
- )
- guard lastNearBottom != isNearBottom else { return }
- lastNearBottom = isNearBottom
- onNearBottomChange?(isNearBottom)
- }
}
-}
#endif
enum ComposerRecordingGestureResolution: Equatable {
@@ -1703,7 +2276,9 @@ struct ComposerRecordingGesturePolicy {
static let longPressDelayNanoseconds: UInt64 = 240_000_000
static let minimumLongPressDuration: TimeInterval = 0.22
- static func resolution(for translation: CGSize) -> ComposerRecordingGestureResolution {
+ static func resolution(for translation: CGSize)
+ -> ComposerRecordingGestureResolution
+ {
let cancelProgress = max(0, -translation.width) / cancelDistance
let lockProgress = max(0, -translation.height) / lockDistance
if cancelProgress >= 1, cancelProgress >= lockProgress {
@@ -1717,7 +2292,10 @@ struct ComposerRecordingGesturePolicy {
static func cancelHintOpacity(for translation: CGSize) -> Double {
let leftwardDistance = max(CGFloat.zero, -translation.width)
- let fadeAmount = min(maximumCancelHintFade, leftwardDistance / cancelHintFadeDistance)
+ let fadeAmount = min(
+ maximumCancelHintFade,
+ leftwardDistance / cancelHintFadeDistance
+ )
return Double(CGFloat(1) - fadeAmount)
}
@@ -1762,22 +2340,6 @@ private struct TelegramRecordButton: View {
var body: some View {
ZStack {
- Circle()
- .fill(.ultraThinMaterial)
- .overlay {
- Circle()
- .fill(isRecording ? Color.red.opacity(0.13) : Color.clear)
- }
- .overlay(
- Circle()
- .stroke(
- isRecording
- ? Color.red.opacity(0.42)
- : Color.secondary.opacity(0.2),
- lineWidth: 0.75
- )
- )
-
if isFinalizing {
ProgressView()
.controlSize(.small)
@@ -1791,34 +2353,35 @@ private struct TelegramRecordButton: View {
}
.frame(width: 42, height: 42)
.scaleEffect(isRecording && !isLocked ? 1.34 : (isPressing ? 1.08 : 1))
- .shadow(
- color: isRecording ? Color.black.opacity(0.2) : Color.black.opacity(0.075),
- radius: 8,
- y: isRecording ? 4 : 2
- )
.contentShape(Circle())
-#if os(iOS)
- .overlay {
- ComposerRecordTouchSurface(
- onBegan: { beginPhysicalPress(at: $0) },
- onMoved: movePhysicalPress,
- onEnded: { translation, eventTime in
- endPhysicalPress(translation, at: eventTime)
- },
- onCancelled: cancelPhysicalPress
- )
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .accessibilityHidden(true)
- }
-#else
- .gesture(recordGesture)
-#endif
+ .glassEffect()
+ #if os(iOS)
+ .overlay {
+ ComposerRecordTouchSurface(
+ onBegan: { beginPhysicalPress(at: $0) },
+ onMoved: movePhysicalPress,
+ onEnded: { translation, eventTime in
+ endPhysicalPress(translation, at: eventTime)
+ },
+ onCancelled: cancelPhysicalPress
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityHidden(true)
+ }
+ #else
+ .gesture(recordGesture)
+ #endif
.opacity(isEnabled ? 1 : 0.45)
.allowsHitTesting(isEnabled && !isFinalizing)
- .animation(.spring(response: 0.24, dampingFraction: 0.78), value: isRecording)
+ .animation(
+ .spring(response: 0.24, dampingFraction: 0.78),
+ value: isRecording
+ )
.animation(.easeOut(duration: 0.12), value: isPressing)
.accessibilityLabel(accessibilityLabel)
- .accessibilityHint("Во время записи проведите влево для отмены или вверх для фиксации")
+ .accessibilityHint(
+ "Во время записи проведите влево для отмены или вверх для фиксации"
+ )
.onDisappear {
longPressTask?.cancel()
longPressTask = nil
@@ -1832,7 +2395,8 @@ private struct TelegramRecordButton: View {
private var accessibilityLabel: String {
if isLocked { return "Отправить записанное сообщение" }
- return isRecording ? mode.recordingAccessibilityLabel : mode.idleAccessibilityLabel
+ return isRecording
+ ? mode.recordingAccessibilityLabel : mode.idleAccessibilityLabel
}
private var recordGesture: some Gesture {
@@ -1873,9 +2437,10 @@ private struct TelegramRecordButton: View {
longPressTask?.cancel()
longPressTask = nil
let wasLongPress = didBeginLongPress
- let elapsed = pressStartedAt.map {
- eventTime.timeIntervalSince($0)
- } ?? 0
+ let elapsed =
+ pressStartedAt.map {
+ eventTime.timeIntervalSince($0)
+ } ?? 0
isPressing = false
didBeginLongPress = false
pressStartedAt = nil
@@ -1912,7 +2477,8 @@ private struct TelegramRecordButton: View {
longPressTask = Task { @MainActor in
do {
try await Task.sleep(
- nanoseconds: ComposerRecordingGesturePolicy.longPressDelayNanoseconds
+ nanoseconds: ComposerRecordingGesturePolicy
+ .longPressDelayNanoseconds
)
} catch {
return
@@ -1925,102 +2491,116 @@ private struct TelegramRecordButton: View {
}
#if os(iOS)
-/// A raw UIKit touch surface keeps ownership of the physical finger while the
-/// SwiftUI composer swaps its idle controls for the recording preview. A
-/// SwiftUI DragGesture can be cancelled by that hierarchy update and used to
-/// report the cancellation as an early end on some iOS releases.
-@MainActor
-private struct ComposerRecordTouchSurface: UIViewRepresentable {
- let onBegan: (Date) -> Void
- let onMoved: (CGSize) -> Void
- let onEnded: (CGSize, Date) -> Void
- let onCancelled: () -> Void
+ /// A raw UIKit touch surface keeps ownership of the physical finger while the
+ /// SwiftUI composer swaps its idle controls for the recording preview. A
+ /// SwiftUI DragGesture can be cancelled by that hierarchy update and used to
+ /// report the cancellation as an early end on some iOS releases.
+ @MainActor
+ private struct ComposerRecordTouchSurface: UIViewRepresentable {
+ let onBegan: (Date) -> Void
+ let onMoved: (CGSize) -> Void
+ let onEnded: (CGSize, Date) -> Void
+ let onCancelled: () -> Void
- func makeUIView(context: Context) -> TouchView {
- let view = TouchView(frame: .zero)
- update(view)
- return view
- }
-
- func updateUIView(_ uiView: TouchView, context: Context) {
- update(uiView)
- }
-
- private func update(_ view: TouchView) {
- view.onBegan = onBegan
- view.onMoved = onMoved
- view.onEnded = onEnded
- view.onCancelled = onCancelled
- }
-
- final class TouchView: UIView {
- var onBegan: ((Date) -> Void)?
- var onMoved: ((CGSize) -> Void)?
- var onEnded: ((CGSize, Date) -> Void)?
- var onCancelled: (() -> Void)?
-
- private var activeTouch: UITouch?
- private var origin = CGPoint.zero
-
- override init(frame: CGRect) {
- super.init(frame: frame)
- backgroundColor = .clear
- isOpaque = false
- isExclusiveTouch = true
- isMultipleTouchEnabled = false
- isAccessibilityElement = false
+ func makeUIView(context: Context) -> TouchView {
+ let view = TouchView(frame: .zero)
+ update(view)
+ return view
}
- required init?(coder: NSCoder) {
- nil
+ func updateUIView(_ uiView: TouchView, context: Context) {
+ update(uiView)
}
- override func touchesBegan(_ touches: Set, with event: UIEvent?) {
- guard activeTouch == nil, let touch = touches.first else { return }
- activeTouch = touch
- origin = touch.location(in: window)
- onBegan?(eventDate(for: touch))
+ private func update(_ view: TouchView) {
+ view.onBegan = onBegan
+ view.onMoved = onMoved
+ view.onEnded = onEnded
+ view.onCancelled = onCancelled
}
- override func touchesMoved(_ touches: Set, with event: UIEvent?) {
- guard let touch = trackedTouch(in: touches) else { return }
- onMoved?(translation(for: touch))
- }
+ final class TouchView: UIView {
+ var onBegan: ((Date) -> Void)?
+ var onMoved: ((CGSize) -> Void)?
+ var onEnded: ((CGSize, Date) -> Void)?
+ var onCancelled: (() -> Void)?
- override func touchesEnded(_ touches: Set, with event: UIEvent?) {
- guard let touch = trackedTouch(in: touches) else { return }
- let translation = translation(for: touch)
- let date = eventDate(for: touch)
- activeTouch = nil
- onEnded?(translation, date)
- }
+ private var activeTouch: UITouch?
+ private var origin = CGPoint.zero
- override func touchesCancelled(_ touches: Set, with event: UIEvent?) {
- guard trackedTouch(in: touches) != nil else { return }
- activeTouch = nil
- onCancelled?()
- }
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ backgroundColor = .clear
+ isOpaque = false
+ isExclusiveTouch = true
+ isMultipleTouchEnabled = false
+ isAccessibilityElement = false
+ }
- private func trackedTouch(in touches: Set) -> UITouch? {
- guard let activeTouch else { return nil }
- return touches.first(where: { $0 === activeTouch })
- }
+ required init?(coder: NSCoder) {
+ nil
+ }
- private func translation(for touch: UITouch) -> CGSize {
- let location = touch.location(in: window)
- return CGSize(
- width: location.x - origin.x,
- height: location.y - origin.y
- )
- }
+ override func touchesBegan(
+ _ touches: Set,
+ with event: UIEvent?
+ ) {
+ guard activeTouch == nil, let touch = touches.first else {
+ return
+ }
+ activeTouch = touch
+ origin = touch.location(in: window)
+ onBegan?(eventDate(for: touch))
+ }
- private func eventDate(for touch: UITouch) -> Date {
- Date().addingTimeInterval(
- touch.timestamp - ProcessInfo.processInfo.systemUptime
- )
+ override func touchesMoved(
+ _ touches: Set,
+ with event: UIEvent?
+ ) {
+ guard let touch = trackedTouch(in: touches) else { return }
+ onMoved?(translation(for: touch))
+ }
+
+ override func touchesEnded(
+ _ touches: Set,
+ with event: UIEvent?
+ ) {
+ guard let touch = trackedTouch(in: touches) else { return }
+ let translation = translation(for: touch)
+ let date = eventDate(for: touch)
+ activeTouch = nil
+ onEnded?(translation, date)
+ }
+
+ override func touchesCancelled(
+ _ touches: Set