Этап 5: история — экспорт и импорт .bare, устройства, место, пагинация
Экспорт: ключ архива из секрета аккаунта через HKDF, заголовок ровно 65 байт (magic, версия, соль, 32 сырых байта отпечатка владельца, iv) и он же целиком AAD шифротекста. Ника владельца в файле нет. Импорт сверяет отпечаток до расшифровки, сливает идемпотентно по id, а записи peers берёт только для ников, которых в локальном TOFU ещё нет: архивом доверие к ключу не перебить. Настройки: устройства с датой и пометкой «это устройство», «занято N МБ», кнопка «экспортировать» в подтверждении выхода и в подтверждении входа под другим ником — долг этапа 1 и обещание ADR-029 закрыты. Лента: страницы по 50 с подгрузкой вверх без прыжка прокрутки; новая страница вставляется, а не пересобирает ленту. ADR-050: импорт не перезаписывает лежащую запись — у своей есть состояние отправки, которого в архиве нет. ADR-054: архив — недоверенный ввод. Ревью собрало архивы с ts вне диапазона Date, мусорным lastId, ником с bidi-переопределением и roomId с обходом пути: каждый из них навсегда ломал ленту или счётчик. Теперь форма ника, roomId, id, ts и автора проверяется, а lastId из файла не читается вовсе. ADR-051, 052, 053: тексты и кнопки, устройства и место, страницы ленты без виртуализации. Приёмка: формат сверен побайтно на модулях, скачанных с боевого сервера, — смещения заголовка, отпечаток сырыми байтами, новые соль и iv на каждый экспорт, подмена любого байта заголовка ломает расшифровку, чужой секрет не открывает, семь видов битых файлов отвергнуты. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -38,7 +38,7 @@ Forward secrecy — осознанный non-goal v1.
|
||||
|
||||
Сервер хранит только три вещи: аккаунты (ник, argon2-хеш, зашифрованный ключевой блоб), метаданные комнат и контактов (включая завёрнутые ключи комнат), транзитную очередь зашифрованных недоставленных сообщений. Очередь per-device: устройство — случайный идентификатор, который клиент создаёт при первом входе; доставлено и подтверждено ACK — удалено с сервера; не забрано за 30 дней — удалено. Схема — `docs/storage.md`, протокол — `docs/protocol.md`.
|
||||
|
||||
Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений, виртуализация списка в DOM.
|
||||
Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений; загруженное держится в DOM целиком, виртуализации нет (ADR-053).
|
||||
|
||||
При старте клиент запрашивает `navigator.storage.persist()` и показывает занятое место через `storage.estimate()`.
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ header = "BARE" (4) || version u8 = 1 || salt (16) || fingerprint (32) || iv
|
||||
|
||||
Импорт: проверить magic и версию, сравнить `fingerprint` со своим — при несовпадении показать «архив создан другим аккаунтом» и остановиться, иначе вывести ключ и расшифровать. Слияние — идемпотентное по `id` сообщений и `id` чатов; записи `peers` из архива добавляются только для ников, которых в локальном TOFU ещё нет.
|
||||
|
||||
Чужая магия и незнакомая версия — файла в заголовке или нагрузки в поле `v` — показываются тем же текстом, что и порча: «файл повреждён». Третьего текста нет (ADR-054). Форму записей внутри нагрузки клиент проверяет сам — `docs/storage.md`, «Экспорт `.bare`».
|
||||
|
||||
## Идентификаторы
|
||||
|
||||
- ULID: 48 бит миллисекунд + 80 бит случайности, Crockford base32, 26 символов. Внутри одной миллисекунды на одном клиенте случайная часть инкрементируется.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ADR-009: История — только на устройстве, в IndexedDB
|
||||
|
||||
Уточнён [ADR-053](053-feed-pages-without-virtualization.md): виртуализация списка в DOM снята, пагинация курсором по 50 в силе.
|
||||
|
||||
## Контекст
|
||||
|
||||
История, живущая на сервере, делает сервер архивом и целью атак. У Bare история — собственность устройства.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-050: Импорт архива не перезаписывает то, что уже лежит
|
||||
|
||||
## Контекст
|
||||
|
||||
`docs/crypto.md` описывает слияние одной строкой: «идемпотентное по `id` сообщений и `id` чатов». Что делать с записью, которая на устройстве уже есть, там не сказано, а вариантов два, и они дают разную историю.
|
||||
|
||||
ADR-034 такой же вопрос уже решал — для входящего из сети. Его довод к архиву не относится: `id` открыт собеседнику, и потому конверт с известным `id` игнорируется, а архив зашифрован секретом аккаунта, чужой его не соберёт. Значит правило нужно выбирать заново, а не наследовать.
|
||||
|
||||
Молчат и три соседних места. Счётчик непрочитанных и граница «новых» в архив не пишутся (`docs/storage.md`) — но что происходит с местными, когда приходит история за прошлый год, не сказано. `pending` — сообщение, набранное на другом устройстве и туда же не ушедшее, — по букве документа в архив попадает: текст у него есть. И `lastId` чата в архив попадает тоже, хотя указывает на последнюю строку чата, а ею бывает как раз то, чего в архиве нет.
|
||||
|
||||
## Решение
|
||||
|
||||
- Импорт не перезаписывает существующую запись сообщения. Своя запись знает то, чего в архиве нет: состояние отправки у каждого устройства своё — на одном сообщение `failed`, на другом то же самое доставлено, — а нерасшифрованная хранит `raw`.
|
||||
- Чат с известным `id` не перезаписывается. Меняется одно: `lastId` уезжает вперёд под самое новое из добавленного, иначе чат не встанет на своё место в списке.
|
||||
- `lastId` в архив не пишется. Устройство, принявшее архив, считает его само — по тому, что действительно добавило. Взятый из файла, он указывал бы на строку, которой в архиве нет: последней в чате бывает и неотправленная, и нерасшифрованная. Такой `lastId` ставит пустой чат в начало списка, а после открытия чата уезжает в `lastReadId` — и настоящее сообщение с тем же `id`, приехав позже, не поднимет счётчик.
|
||||
- Счётчик непрочитанных импорт не трогает: архив приносит переписку, а не отметки о прочтении. Граница «новых» едет за лентой: `lastReadId` уезжает под новый `lastId`, пока непрочитанных у чата нет; у чата с непрочитанным граница уже показывает на него и остаётся на месте. Счётчик и граница считаются от одной точки — иначе счётчик говорит «1», а линия отчёркивает всю привезённую переписку.
|
||||
- `hidden` в архив не пишется. «Убрать из списка» — решение устройства, а не история: перенесённое, оно спрятало бы привезённую переписку на новом устройстве, и показать её было бы нечем.
|
||||
- В архив уносится только отправленное — `sent`. `pending` и `failed` привязаны к устройству и к своему ULID (ADR-036): на другом устройстве «повторить» у такой записи отправит собеседнику второе сообщение, а сама запись, ушедшая после повтора под свежим `id`, вернётся из того же файла дублем. Нерасшифрованное не уносится тоже: без текста от записи остаётся один заголовок, а `raw` — служебное поле.
|
||||
- Ответ импорта — число добавленных сообщений: «добавлено N сообщений». Повторный импорт того же файла добавляет ноль.
|
||||
- Лента открытого чата после импорта перечитывается целиком: добавленное ложится в середину пачками по несколько тысяч, и перечня в событии нет.
|
||||
- Правила записаны в `docs/storage.md`, раздел «Экспорт `.bare`».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Повторный импорт и склейка истории с двух устройств не создают дублей и не двигают ни одной прежней строки. Это верно и после «повторить»: отвергнутого в архиве нет.
|
||||
- Нерасшифрованное остаётся нерасшифрованным, даже когда в архиве есть его текст. Чинит это `raw` и появившийся ключ, а не файл. Цена принята: правило одно и без исключений, а исключение стоило бы разбора, чья запись новее.
|
||||
- Импорт истории годовой давности не превращает список чатов в стену непрочитанных и не отчёркивает её линией «новые».
|
||||
- Архив, собранный устройством, у которого что-то не ушло, не заставляет второе устройство отправлять это за него.
|
||||
- Неотправленное и отвергнутое живут ровно на одном устройстве. Потеря устройства без экспорта уносит их — как и всё, что не успело стать историей.
|
||||
- Чат, убранный из списка на одном устройстве, на другом виден: вместе с ним видна и привезённая переписка. Комната, из которой мы вышли, прячется обратно при следующем `ready` — состав комнаты знает сервер (ADR-044).
|
||||
- Чат, у которого в архиве нет ни одной строки истории, приезжает пустым и встаёт в конец списка: `lastId` у него пуст.
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-051: Кнопка «экспортировать» в подтверждениях и тексты архива
|
||||
|
||||
## Контекст
|
||||
|
||||
История на устройстве — единственная копия, и стирают её два экрана: «выйти» в настройках и вход под другим ником (ADR-029). `docs/ui.md` держит кнопку «экспортировать» только в первом из них, потому что второго ADR-029 коснулся тогда, когда экспорта не существовало вовсе, и прямо пообещал: «когда он появится, кнопка придёт сюда тем же порядком — сначала `docs/ui.md`». Экспорт появился.
|
||||
|
||||
Ко второму экрану вопросов нет: секрет прежнего аккаунта лежит на устройстве, а сессия экспорту не нужна — архив собирается из IndexedDB (ADR-014).
|
||||
|
||||
Тексты раздела «история» перечислены, но двух вещей в них нет. «добавлено N сообщений» не сходится с числом: при одном сообщении получается «добавлено 1 сообщений». И отказ, который не про файл: истории не прочитать, места на устройстве нет, ключей аккаунта нет — в перечне ответов такого нет, а показывать его надо: молчащая кнопка «экспортировать» перед стиранием истории — худший из возможных исходов.
|
||||
|
||||
## Решение
|
||||
|
||||
- Подтверждение входа под другим ником получает третью кнопку: «экспортировать», «удалить», «отмена». Порядок и место — как у подтверждения выхода: «экспортировать», «выйти», «отмена».
|
||||
- Экспорт подтверждение не закрывает: архив скачался, а стирать историю или нет — отдельное решение того же человека.
|
||||
- Начальный фокус в обоих подтверждениях — на «экспортировать»: с неё безопасно начинать.
|
||||
- «добавлено N сообщений» согласуется с числом: «добавлено 1 сообщение», «добавлено 2 сообщения», «добавлено 5 сообщений».
|
||||
- «Тексты состояний» получают две строки: «экспорт не удался» — архив не собрался; «импорт не удался» — разобранный архив не дошёл до базы. Порча самого файла и чужой архив говорят о себе своими словами, они уже в перечне.
|
||||
- Всё перечисленное записано в `docs/ui.md`: «Вход и регистрация», «Настройки», «Тексты состояний».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Единственная копия истории не исчезает без предложения сохранить её ни на одном экране.
|
||||
- Кнопок в подтверждении три, и в один ряд они помещаются не всегда: «экспортировать» в моноширинном шрифте шире трети колонки настроек, а насколько — решает системный шрифт платформы. Панель переносит их сама, цель нажатия остаётся 44 px.
|
||||
- Обещание ADR-029 закрыто.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR-052: Своё устройство из настроек не удаляется; занятое место — оценка браузера
|
||||
|
||||
## Контекст
|
||||
|
||||
`docs/ui.md` описывает раздел «устройства» одной строкой: список `id` (первые 8 символов), дата, «это устройство», «удалить». Три вещи в ней не решены, а решить их надо в коде.
|
||||
|
||||
Первая — что делает «удалить» у своей строки. `DELETE /api/devices/{id}` уносит очередь, подписку и сессии устройства (`docs/protocol.md`). На своём это означает: следующий же запрос получает `401 unauthenticated` и по `docs/ui.md` («Сеть и состояния») уводит на экран входа с целой IndexedDB. Выходом это не является: «выйти» стирает историю и сначала предлагает её сохранить (ADR-051). Получается третье состояние, которого в документе нет, — выход без вопроса и без стирания, с прежним `deviceId` в `meta`, который при следующем входе заведёт устройство заново.
|
||||
|
||||
Вторая — какая дата. Сервер отдаёт две: `createdAt` и `lastSeen`.
|
||||
|
||||
Третья — что такое `N` в «занято N МБ». Байты `storage.estimate()` в мегабайтах дают дробь с десятком знаков, а браузер, который `estimate()` не умеет, не даёт и её.
|
||||
|
||||
## Решение
|
||||
|
||||
- Своё устройство из раздела не удаляется. У своей строки вместо кнопки стоит пометка «это устройство». Отцепляет текущее устройство «выйти»: там и вопрос про историю, и стирание базы.
|
||||
- Сервер не меняется: `DELETE /api/devices/{id}` принимает любое своё устройство, включая текущее. Запрет — правило экрана, а не протокола: устройство, потерявшее сессию с чужой руки, обязано оставаться рабочим сценарием.
|
||||
- Дата в строке — дата появления устройства (`createdAt`), в местной зоне, цифрами: `22.08.2026`. `lastSeen` не показывается: список нужен, чтобы узнать своё среди чужих и отцепить лишнее.
|
||||
- «занято N МБ» — `usage` из `navigator.storage.estimate()`, МБ равен 1024×1024 байтам. Число человеческое: до десятых, пока меньше десяти, дальше целое; десятые округляются вверх, потому что пара сотен килобайт — это не «0 МБ». Браузер без `estimate()` строки не получает: писать в неё нечего.
|
||||
- Записано в `docs/ui.md`, «Настройки».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Потерянное устройство отцепляется с любого другого; текущее — выходом.
|
||||
- Кнопки «удалить» у своей строки нет никогда, даже когда устройство одно.
|
||||
- Занятое место — оценка происхождения целиком, а не сумма длин записей: индексы и служебные страницы IndexedDB тоже место. Она же намеренно грубая у самого браузера, и точнее показывать нечего.
|
||||
@@ -0,0 +1,27 @@
|
||||
# ADR-053: Лента страницами по 50, без виртуализации списка
|
||||
|
||||
Уточняет [ADR-009](009-local-history.md): пагинация курсором остаётся, виртуализация снимается.
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-009 задаёт одной строкой две разные вещи: «пагинация курсором по ~50 сообщений, виртуализация списка в DOM». Первая — про данные и решает настоящую задачу: чат в десять тысяч сообщений не должен читаться из IndexedDB целиком при открытии. Вторая — про разметку, и её цена выяснилась только на этапе 5.
|
||||
|
||||
Виртуализация требует знать высоту строки до отрисовки. В ленте её нет: текст переносится, на десктопе строка — две ячейки грида через `display: contents` (`docs/identity/brief.md`), сообщение бывает в одну строку и в тридцать. Значит нужны измерение каждой строки, распорки сверху и снизу и пересчёт при смене ширины окна. Платят за это не только кодом: `aria-live` на ленте (`docs/ui.md`, «Доступность») зачитывает появление и исчезновение строк, а поиск по странице и выделение текста перестают видеть то, что убрано из разметки.
|
||||
|
||||
Выгоды при этом нет. В DOM попадает не вся история, а только то, что человек домотал прокруткой: открытие чата — 50 строк независимо от размера переписки.
|
||||
|
||||
## Решение
|
||||
|
||||
- Виртуализации в v1 нет. В разметке живёт всё загруженное.
|
||||
- Лента открывается последней страницей в 50 сообщений и стоит в конце.
|
||||
- Прокрутка к верхнему краю берёт следующие 50 назад по индексу `chat` (`docs/storage.md`). Страница короче полной означает, что выше ничего нет.
|
||||
- Расстояние до низа при подгрузке сохраняется: то, что человек читает, не двигается.
|
||||
- Страница короче окна прокрутки события `scroll` не порождает, поэтому следующая берётся сразу — пока лента не заполнит окно или сообщения не кончатся.
|
||||
- Разделители дат и «новые» считаются по всему загруженному, а не по последней странице: граница «новых» уезжает вверх вместе с подгруженным.
|
||||
- Записано в `docs/ui.md` («Чат») и `docs/architecture.md`.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Домотавший до начала переписки в десять тысяч сообщений держит их все в разметке. Это его прокрутка и его выбор; обычное открытие чата — 50 строк.
|
||||
- Экранный диктор, поиск по странице и выделение работают как в обычном документе.
|
||||
- Возврат виртуализации — отдельный ADR, если появится жалоба, а не предположение.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR-054: Архив — недоверенный ввод: форму записей проверяет клиент
|
||||
|
||||
## Контекст
|
||||
|
||||
Архив собрал владелец аккаунта: ключ выводится из секрета аккаунта, а заголовок целиком лежит под тегом AEAD (ADR-014). Отсюда легко сделать неверный вывод — что содержимому файла можно верить.
|
||||
|
||||
Разбирается он на устройстве и ложится в базу рядом с настоящей историей. На сетевом пути форму держит сервер (`internal/api/valid.go`): ник — `[a-z0-9_]{2,32}` (ADR-019), идентификаторы — 22 символа base64url (`docs/crypto.md`), `ts` сервер ставит сам (ADR-017). Поэтому `sync.js` и обходится проверкой типа. У архива такой опоры нет: тег AEAD ловит порчу, но всё, что лежит под тегом, написал клиент — своей же прошлой или будущей версии. Архив живёт дольше версии, которая его собрала, и его разбор — единственное место, где клиент ест данные, которых больше никто не проверял.
|
||||
|
||||
Цена видна на двух примерах. `ts` вне диапазона `Date` роняет отрисовку ленты на своей строке: `Intl` бросает `RangeError`, лента обрывается, чат не открывается больше никогда. Чат с ником не по форме нельзя ни открыть маршрутом (`docs/ui.md`, «Каркас»), ни убрать из списка — карточка контакта до такого ника не доходит. Убрать негодную запись из базы нечем: экрана для этого нет и не будет.
|
||||
|
||||
Отдельный вопрос — незнакомая версия. Клиент отвечает на неё тем же текстом, что и на порчу, а `docs/ui.md` этого не говорит.
|
||||
|
||||
## Решение
|
||||
|
||||
- Форма проверяется при разборе, до записи в базу. Ник — `[a-z0-9_]{2,32}` (ADR-019); `roomId` — 22 символа base64url (`docs/crypto.md`, «Идентификаторы»); `id` сообщения — ULID; `ts` — целое от нуля до 8 640 000 000 000 000 (предел `Date`). Автор сообщения в личном чате — свой ник или ник собеседника: третьего в переписке двоих не бывает. В комнате автором бывает и вышедший участник, поэтому там сверяется только форма ника.
|
||||
- Что не по форме, до базы не доходит: пропускается запись целиком, а не поле.
|
||||
- Ничего, что устройство может посчитать само, из архива не читается: `lastId` чата считается по добавленному (ADR-050).
|
||||
- Незнакомая версия — файла в заголовке или нагрузки в поле `v` — показывается как «файл повреждён». Третьего текста нет: разобрать такой архив это устройство всё равно не может, а строку под формат, которого ещё нет, пришлось бы придумывать.
|
||||
- Записано в `docs/storage.md` и `docs/crypto.md`.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Запись, которую нельзя ни открыть, ни убрать, в базу не попадает.
|
||||
- Правила формы живут в двух местах: на сервере — для сети, в клиенте — для архива. Это цена того, что архив приходит с диска, а не из протокола.
|
||||
- Архив будущей версии старый клиент назовёт повреждённым. Цена принята: версия формата пока одна, а вторая заведёт свой текст тем же порядком — сначала `docs/ui.md`.
|
||||
- Проверка не защищает от оператора и не претендует на это: подделать архив без секрета аккаунта нельзя, а порчу ловит тег AEAD. Она защищает от собственных ошибок — от того, что записал клиент другой версии, и от того, что запишет он же завтра.
|
||||
+5
-1
@@ -149,4 +149,8 @@ peers key: nick
|
||||
|
||||
## Экспорт `.bare`
|
||||
|
||||
Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw` и `error`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare-<nick>-<YYYY-MM-DD>.bare`.
|
||||
Полезная нагрузка — `chats` (без `lastId`, `unread`, `lastReadId`, `hidden`), `messages` (без `raw` и `error`), `peers` (без `pending`). Уносится только отправленное — `sent`. Неотправленное и отвергнутое остаются устройству: `pending` и `failed` — незаконченная и отвергнутая попытки, привязанные к своему ULID (ADR-036), а не история. Нерасшифрованное не уносится: без текста от записи остаётся один заголовок, а `raw` — служебное поле. Показания устройства — место чата в списке, счётчики и «убрано из списка» — не уносятся тоже (ADR-050). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare-<nick>-<YYYY-MM-DD>.bare`.
|
||||
|
||||
Архив — недоверенный ввод: форму каждой записи клиент проверяет сам, до записи в базу (ADR-054). Ник — `[a-z0-9_]{2,32}`, `roomId` — 22 символа base64url, `id` сообщения — ULID, `ts` — целое в пределах `Date`; автор сообщения в личном чате — свой ник или ник собеседника. Что не по форме, до базы не доходит: пропускается запись целиком, а не поле.
|
||||
|
||||
Импорт вливает архив одной транзакцией и не трогает то, что уже лежит (ADR-050): сообщение и чат с известным `id` остаются как есть, `unread` и `hidden` не меняются, запись `peers` добавляется только для ника, которого в TOFU ещё нет. У чата двигается `lastId` — под самое новое из добавленного, — и вместе с ним граница «новых»: `lastReadId` уезжает под новый `lastId`, пока непрочитанных у чата нет. Ответ — число добавленных сообщений; повторный импорт того же файла добавляет ноль.
|
||||
|
||||
+8
-4
@@ -18,7 +18,7 @@
|
||||
|
||||
Кнопка одна, в стиле строки ввода. Пока идёт PBKDF2 — состояние «вычисляем ключ…», кнопка заблокирована. Ошибки — строкой под формой цветом `mark`: «неверный ник или пароль», «ник занят», «ник: 2–32 символа, a–z, 0–9, _», «нужен инвайт-код», «инвайт-код не подходит». Форму ника и длину пароля клиент проверяет сам, до PBKDF2, в обоих режимах. Остальные состояния — «Тексты состояний».
|
||||
|
||||
Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «удалить» и «отмена» (ADR-029). База стирается после успешного входа или регистрации; отказ сервера её не трогает.
|
||||
Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «экспортировать», «удалить» и «отмена» (ADR-029, ADR-051). «экспортировать» скачивает `.bare` прежнего аккаунта и подтверждение не закрывает; сессия для этого не нужна. База стирается после успешного входа или регистрации; отказ сервера её не трогает.
|
||||
|
||||
## Список чатов (сайдбар)
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
|
||||
Лента: десктоп — сетка «автор 132 px + текст», подряд идущие сообщения одного автора — без повтора автора; мобильный — автор над группой. Свой ник в колонке автора — цветом `mark`. Разделители дат — линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
|
||||
|
||||
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
|
||||
|
||||
Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение». Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500.
|
||||
|
||||
Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038).
|
||||
@@ -59,10 +61,10 @@
|
||||
- «ты: @nick», свой отпечаток.
|
||||
- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка «включить» или «выключить». `запрещены в браузере` — разрешение отклонено или уведомлений в браузере нет вовсе; кнопки в этом состоянии нет (ADR-046). На iOS вне PWA — состояние `выключены` и вместо кнопки текст про установку, тот же, что в баннере.
|
||||
- «установить приложение»: кнопка «установить», если есть `beforeinstallprompt`; на iOS вне PWA — инструкция «поделиться → на экран «домой»». Устанавливать нечего — раздела нет.
|
||||
- «устройства»: список `id` (первые 8 символов), дата, «это устройство», «удалить».
|
||||
- «история»: «занято N МБ»; «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён».
|
||||
- «устройства»: список `id` (первые 8 символов), дата появления, «удалить». У своей строки кнопки нет: вместо неё пометка «это устройство». Своё устройство отсюда не отцепляется — это делает «выйти», где спрашивают про историю (ADR-052).
|
||||
- «история»: «занято N МБ» — оценка браузера, до десятых, пока меньше десяти, дальше целые; браузер, который её не даёт, строки не показывает (ADR-052). «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». Число согласуется со словом: «добавлено 1 сообщение», «добавлено 2 сообщения», «добавлено 5 сообщений» (ADR-051).
|
||||
- «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён».
|
||||
- «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена».
|
||||
- «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена». «экспортировать» — то же скачивание, что и в разделе «история»; подтверждение оно не закрывает (ADR-051).
|
||||
- «удалить аккаунт»: пароль + подтверждение «аккаунт и вся история будут удалены навсегда.» с кнопками «удалить» и «отмена».
|
||||
|
||||
## Баннер установки (iOS)
|
||||
@@ -99,6 +101,8 @@
|
||||
| ключевой блоб не разобран, не расшифрован или не соответствует публичному ключу | «ключ аккаунта повреждён» |
|
||||
| `iter` блоба не равен ответу `GET /api/kdf` | «параметры ключа не совпали» |
|
||||
| `401 invalid_credentials` в настройках | «неверный пароль» |
|
||||
| архив не собрался: истории не прочитать или ключей аккаунта на устройстве нет | «экспорт не удался» |
|
||||
| разобранный архив не дошёл до базы: нет места или ключей аккаунта | «импорт не удался» |
|
||||
|
||||
## Доступность
|
||||
|
||||
|
||||
Reference in New Issue
Block a user