7 Commits
22 changed files with 515 additions and 396 deletions
+64 -17
View File
@@ -1,28 +1,75 @@
# Bare
<p align="center">
<img src="docs/identity/hero.png" alt="" width="100%">
</p>
Максимально простой веб-чат. Web-native PWA на ванильных технологиях: HTML, CSS, vanilla JS — и Go-сервер одним бинарём. Ноль npm-зависимостей, никакой сборки.
<h1 align="center">bare</h1>
Bare — маленький независимый инструмент, а не конкурент Discord, Slack или Telegram. Название буквальное: голый, без лишнего, ничего не спрятано за слоями абстракций.
<p align="center">
сообщения шифруются в браузере · история остаётся на устройстве · сервер доставляет шифротекст
</p>
Сервер — реле, а не архив: он передаёт зашифрованные сообщения и забывает их. История живёт только на устройстве. E2EE по умолчанию — оператор видит только шифротекст. Забыл пароль — значит забыл: восстановления нет, потому что пароль — материал ключа.
<p align="center">
<a href="https://bare.xmatic.team">открыть bare</a> ·
<a href="docs/philosophy.md">философия</a> ·
<a href="docs/threat-model.md">модель угроз</a>
</p>
## Документация
## что это
- [Философия](docs/philosophy.md) — манифест
- [Архитектура](docs/architecture.md) — обзор системы
- [Модель угроз](docs/threat-model.md) — от чего защищаемся и от чего нет
- [Решения](docs/decisions/) — ADR по ключевым решениям
- [Криптография](docs/crypto.md), [протокол](docs/protocol.md), [хранение](docs/storage.md) — спецификации для кода
- [Интерфейс](docs/ui.md) и [айдентика](docs/identity/brief.md)
- [Деплой](docs/deploy.md) и [план реализации](docs/plan.md)
- [Открытые вопросы](docs/open-questions.md)
Bare — маленький независимый E2EE-чат, а не платформа. Он умеет личные чаты и комнаты, работает как PWA, присылает уведомления и переносит локальную историю через зашифрованный файл `.bare`.
## Статус
Клиент — HTML, CSS и vanilla JS без npm и сборки. Сервер — Go + SQLite, статика встроена в один бинарь.
Все шесть этапов `docs/plan.md` сделаны и работают на [bare.xmatic.team](https://bare.xmatic.team): аккаунты, чат 1:1, комнаты с ключом на комнату, TOFU и отпечатки, PWA и пуши, экспорт и импорт истории, лимиты и закалка.
- **без аккаунтной обвязки** — только ник и пароль, без email, телефона и OAuth;
- **локальная история** — сообщения после доставки живут в IndexedDB текущего устройства, а не в облачном архиве;
- **сервер-реле** — сервер хранит метаданные и временную очередь недоставленных шифротекстов, удаляя сообщения после подтверждения устройства;
- **криптография в браузере** — пароль не покидает клиент, личные чаты и комнаты шифруются через WebCrypto;
- **простая поверхность аудита** — клиент состоит из читаемых ES-модулей без бандлера и скрытого шага сборки.
Осталось ручное: прогон сценариев на iOS Safari (установленное на «Домой» приложение), Android Chrome, десктопных Firefox и Safari. Автоматика гоняла только Chrome. Чеклист — в `docs/plan.md`, этап 4.
## внутри
## Лицензия
| слой | решение |
|---|---|
| клиент | HTML, CSS, vanilla JS, PWA, IndexedDB |
| сервер | Go, SQLite, HTTPS, SSE |
| ключи | ECDH P-256, PBKDF2, HKDF, AES-GCM |
| доверие | TOFU и сверка отпечатков при смене ключа |
| история | только на устройстве; ручной экспорт и импорт `.bare` |
## запустить локально
Нужен Go 1.27.
```sh
git clone https://github.com/xmatic-squad/bare.git
cd bare
go run ./cmd/bare serve
```
Откройте [http://127.0.0.1:8411](http://127.0.0.1:8411). Без переменных окружения Bare создаст `bare.db` в текущей папке, разрешит регистрацию и запустится без пушей.
```sh
go test ./...
```
Production-настройка, VAPID, nginx и systemd описаны в [руководстве по развёртыванию](docs/deploy.md).
## честные ограничения
- Забыл пароль — значит забыл: восстановления нет, потому что пароль участвует в выводе ключей.
- История в IndexedDB не зашифрована at rest и доступна коду, работающему на устройстве.
- E2EE не скрывает метаданные, не защищает скомпрометированное устройство и не спасает от оператора, который подменил веб-клиент.
- Forward secrecy в v1 нет. На iOS пуши работают только у PWA, установленного на экран «Домой».
Полная граница обещаний и допущений — в [модели угроз](docs/threat-model.md).
## документация
- [Философия](docs/philosophy.md), [архитектура](docs/architecture.md), [модель угроз](docs/threat-model.md)
- [Криптография](docs/crypto.md), [HTTP-протокол](docs/protocol.md), [хранение](docs/storage.md)
- [Интерфейс](docs/ui.md), [айдентика](docs/identity/brief.md), [архитектурные решения](docs/decisions/)
- [Деплой](docs/deploy.md), [план v1](docs/plan.md), [открытые вопросы](docs/open-questions.md)
## лицензия
[MIT](LICENSE)
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-024: Айдентика «Скобы», интерфейс и язык
Уточнён [ADR-075](075-no-zoom-app-feel.md) (масштабирование запрещено, размеры шрифтов прежние), [ADR-067](067-version-in-sidebar-foot.md) (версия и время коммита в подвале сайдбара) и [ADR-069](069-message-input-is-editable-block.md) (строка сообщения — редактируемый блок).
Уточнён [ADR-075](075-no-zoom-app-feel.md) (масштабирование запрещено, размеры шрифтов прежние), [ADR-067](067-version-in-sidebar-foot.md) (версия и время коммита в подвале сайдбара), [ADR-076](076-ios-input-accessory-is-system.md) (строка сообщения — `textarea`) и [ADR-077](077-message-alignment-by-pointer.md) (стороны сообщений зависят от способа ввода).
## Контекст
+3 -1
View File
@@ -1,5 +1,7 @@
# ADR-064: строка ввода — div, а не form
Пересмотрен [ADR-076](076-ios-input-accessory-is-system.md): ни контейнер, ни вид редактируемого элемента не дают веб-странице управления системной панелью iOS; поле снова стало `textarea`, контейнер `div` оставлен как безвредная деталь реализации.
Уточняет `docs/ui.md`, «Доступность»: `form` в списке семантики держит `role="form"`, а не элемент `<form>`, — для строки ввода чата.
Пересмотрен [ADR-069](069-message-input-is-editable-block.md). Диагноз здесь неверен: полосу помощника форм iOS рисует не из-за `<form>`, а для любых `input` и `textarea`, — вынос поля из формы её не убрал. Контейнер `div` с `role="form"` и кнопка `type="button"` остаются, `textarea` заменён редактируемым блоком — тем самым «следующим шагом», который записан ниже в «Следствиях».
@@ -19,7 +21,7 @@
- `<form class="compose">``<div class="compose" role="form">`. `role="form"` — тот же ориентир для скринридера, что и элемент `<form>`, без вызывающих панель heuristics WebKit.
- Кнопка отправки — `type="button"` с явным `click`, вместо `type="submit"` и события `submit` на форме.
- `autocomplete="off"` на самом поле — на случай, если панель зависит ещё и от него, а не только от `<form>`.
- `textarea`, `maxLength`, `field-sizing: content`, placeholder без изменений.
- На момент решения `textarea`, `maxLength`, `field-sizing: content` и placeholder оставались без изменений; актуальное состояние строки ввода задаёт пересмотревший это решение ADR-076.
Остальные формы (вход, `#/new`, участники, настройки) не тронуты: там несколько полей в форме, панель навигации между ними — по крайней мере, не чистый минус, и незачем менять то, что не жаловались.
@@ -1,5 +1,7 @@
# ADR-065: сообщения выравниваются по стороне — свои вправо, чужие влево
Уточнён [ADR-077](077-message-alignment-by-pointer.md): стороны остаются на телефоне и планшете, на десктопе все сообщения выравниваются слева.
Пересматривает часть [ADR-024](024-identity-and-ui.md): десктопную сетку ленты «автор 132 px + текст» из `docs/identity/brief.md`, «Компоновка». Уточняет `docs/ui.md`, «Чат».
## Контекст
@@ -1,5 +1,7 @@
# ADR-069: Строка сообщения — редактируемый блок, а не поле формы
Пересмотрен [ADR-076](076-ios-input-accessory-is-system.md): проверка на реальном iPhone опровергла главное следствие — системная панель остаётся и у `contenteditable`; строка снова стала `textarea`.
Уточняет [ADR-024](024-identity-and-ui.md) («Чат» и «Доступность» в `docs/ui.md`) и [ADR-075](075-no-zoom-app-feel.md): в перечне элементов управления, по которым второй тап не гасится, прибавился редактируемый блок.
Уточнён [ADR-071](071-input-limit-cuts-what-arrives.md): предел держится до вставки и режет приходящее, а не хвост блока.
@@ -1,5 +1,7 @@
# ADR-071: Предел строки сообщения держится до вставки
Пересмотрен [ADR-076](076-ios-input-accessory-is-system.md): после возврата к `textarea` предел снова держит нативный `maxLength`; ручная обработка вставки больше не нужна.
Уточняет [ADR-069](069-message-input-is-editable-block.md): предел 4000 символов держит по-прежнему сам блок, но режет он приходящее, а не набранное.
## Контекст
+2 -2
View File
@@ -2,7 +2,7 @@
Уточняет [ADR-024](024-identity-and-ui.md): типографика из `docs/identity/brief.md` остаётся как записана, а страница перестаёт масштабироваться.
Уточнён [ADR-069](069-message-input-is-editable-block.md): строка сообщения стала редактируемым блоком, и она в перечне элементов управления, по которым второй тап не гасится.
Уточнён [ADR-076](076-ios-input-accessory-is-system.md): строка сообщения снова стала `textarea`, входит в перечень элементов управления, по которым второй тап не гасится, и заменяет весь нижний отступ на 6 px только при открытой экранной клавиатуре.
## Контекст
@@ -22,7 +22,7 @@
- Страховка на JS — `web/js/zoom.js`: мета-строке Safari верен не всегда. Слушатели непассивные, иначе `preventDefault` не действует. `gesturestart`, `gesturechange`, `gestureend` — события щипка, они бывают только в Safari — гасятся целиком. Второй тап подряд, ближе 350 мс и 40 px к первому, гасится, если он не по элементу управления: `preventDefault` на `touchend` уносит с собой `click`, а нажать кнопку дважды подряд — обычное дело. Прокрутка, свайпы и одиночные нажатия не трогаются вовсе.
- `overscroll-behavior: none` у страницы и `contain` у лент: резинового отскока и «потянуть для обновления» нет, прокрутка внутри лент прежняя.
- Выделение текста снято с шапки, сайдбара, кнопок и разделителей: долгое нажатие по ним показывало лупу и «копировать», а копировать там нечего. С текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара (ADR-067) не снято — их копируют; версию на iPhone взять больше неоткуда, там нет ни строки адреса, ни консоли.
- `viewport-fit=cover` пускает страницу под вырез и системную полосу, поэтому отступы считаются с ней: подвал сайдбара и строка ввода добавляют к нижнему полю `env(safe-area-inset-bottom)`, страница — боковые и верхнюю вставки. Верхняя при `status-bar-style: default` нулевая и не делает ничего; она стоит на случай, когда браузер решит иначе, — строка кода против шапки, уехавшей под часы.
- `viewport-fit=cover` пускает страницу под вырез и системную полосу, поэтому отступы считаются с ней: подвал сайдбара и строка ввода добавляют к нижнему полю `env(safe-area-inset-bottom)`, страница — боковые и верхнюю вставки. Когда открыта экранная клавиатура, обычные `20px + env(safe-area-inset-bottom)` строки ввода заменяются на 6 px по ADR-076: клавиатура уже закрывает домашний индикатор. Верхняя вставка при `status-bar-style: default` нулевая и не делает ничего; она стоит на случай, когда браузер решит иначе, — строка кода против шапки, уехавшей под часы.
## Следствия
@@ -0,0 +1,30 @@
# ADR-076: Панель ввода iOS системная, строка сообщения — `textarea`
Пересматривает [ADR-064](064-composer-not-form.md), [ADR-069](069-message-input-is-editable-block.md) и [ADR-071](071-input-limit-cuts-what-arrives.md). Уточняет `docs/ui.md`, «Чат» и «Доступность».
## Контекст
В v1.1 строка сообщения прошла два варианта: `textarea` вынесли из `<form>`, затем заменили на `contenteditable="plaintext-only"`. Оба решения исходили из предположения, что системная полоса iOS со стрелками и «готово» появляется только у поля формы.
Проверка v1.1 на реальном iPhone опровергла предположение: полоса осталась и у `contenteditable`. Это часть клавиатурного интерфейса WebKit, а не узел страницы. Web API для её отключения нет. В нативной оболочке внешний вид клавиатуры может менять хост iOS; установленная PWA остаётся веб-страницей и такого доступа не получает.
На том же экране часть потерянного места оказалась не системной панелью: строка ввода всегда добавляла `20px + env(safe-area-inset-bottom)`. Safari сохраняет нижний safe-area домашнего индикатора и после появления клавиатуры, хотя её прямоугольная поверхность уже закрывает этот край. Получалась отдельная пустая полоса примерно той же высоты, что системная панель.
`contenteditable` при этом стоил клиенту ручной реализации подсказки, предела, вставки без разметки, переноса строки, `disabled`, счётчика, работы IME и запасного пути для браузеров без `plaintext-only`. Главное следствие ADR-069 не наступило, а цена осталась.
## Решение
- Строка сообщения снова становится `textarea` с нативными `placeholder`, `maxLength`, `disabled`, выделением, вставкой и отменой. Число `rows` считается по фактической высоте текста от одной до семи: это сохраняет автовысоту в Safari без поддержки `field-sizing`. Доступное имя повторяет подсказку через `aria-label`, кнопка `>` объявляется как «отправить»; `autocapitalize`, `autocorrect` и `enterkeyhint` остаются прежними.
- Контейнер `div role="form"` и кнопка `type="button"` остаются. Они не убирают системную панель, но и не создают отдельного поведения; менять их ещё раз без причины незачем.
- Системная панель iOS принимается как ограничение веб-клиента. `inputmode="none"` и собственная экранная клавиатура не используются: убрать вместе с панелью системную клавиатуру означает потерять обычный набор, раскладки, диктовку и доступность.
- Нативная iOS-оболочка не добавляется в Bare. Если абсолютное управление панелью станет требованием, это отдельный продуктовый и архитектурный выбор.
- Тап по неинтерактивному месту истории вызывает `blur()` строки сообщения и закрывает клавиатуру. Кнопки и ссылки в ленте сохраняют своё действие.
- Пока поле сфокусировано и `visualViewport` уменьшен как при экранной клавиатуре, весь обычный нижний отступ строки ввода (`20px + safe-area` на узком экране, `28px + safe-area` на широком) заменяется на 6 px. Эвристика сверяет масштаб и ширину и требует существенного уменьшения высоты; один фокус не считается клавиатурой, поэтому с физической клавиатурой обычный отступ остаётся. При закрытии размеры пересчитываются. Смена ширины после фокуса и уход приложения в фон возвращают полный безопасный отступ до следующего тапа по полю: известное старое значение `visualViewport` после поворота или возврата не должно прижать ввод к домашнему индикатору.
## Следствия
- Строка сообщения снова опирается на браузерное текстовое поле и стала заметно проще. Лишнее не проходит через `maxLength`; ручная обработка `beforeinput`, вставки, IME и текстовых узлов удалена.
- Полоса со стрелками и «готово» на iPhone остаётся. Код и документация больше не обещают веб-странице контроль над системным UI.
- Пустой зазор между строкой ввода и системной панелью исчезает; место возвращается истории без вмешательства в клавиатуру.
- Тап по истории возвращает место ленте без нажатия системной кнопки «готово».
- Проверка самой панели и точного момента перестроения `visualViewport` по-прежнему требует реального iPhone в Safari и установленном PWA; поведение `textarea`, отправки, эвристики размеров и снятия фокуса проверяется в обычном браузере.
@@ -0,0 +1,21 @@
# ADR-077: На десктопе все сообщения слева, на touch свои справа
Уточняет [ADR-065](065-message-side-alignment.md) и `docs/ui.md`, «Чат».
## Контекст
ADR-065 развёл свои и чужие сообщения по сторонам на всех экранах. На телефоне и планшете сторона ускоряет чтение короткой узкой ленты. На десктопе широкая лента даёт обратный эффект: взгляд ходит между краями, хотя цвет собственного ника уже отличает автора.
Порог ширины не отличает тип устройства. Планшет бывает шире небольшого окна ноутбука и не должен менять привычную мобильную сторону только из-за поворота экрана.
## Решение
- На устройстве с точным основным указателем и наведением (`hover: hover` и `pointer: fine`) все блоки сообщений выравниваются по левому краю.
- В остальных случаях сохраняется ADR-065: свои сообщения справа, чужие слева. Это телефоны, планшеты и другие touch-интерфейсы.
- Поток, ограничение ширины блока, автор над текстом, цвет своего ника и полноширинные разделители не меняются.
## Следствия
- Десктопная лента снова читается по одному левому краю, но не возвращается к старой сетке `132px 1fr`.
- Планшет не считается десктопом только из-за ширины экрана.
- Гибридное устройство следует своему основному указателю, который сообщает браузер. Подключение дополнительной мыши само по себе правило не меняет.
@@ -0,0 +1,20 @@
# ADR-078: «Новый чат» — выбор действия
## Контекст
На экране `#/new` одновременно стояли две одинаковые строки: `@ник` и `#имя комнаты`. Это две независимые формы, но экран не говорил «или», а на десктопе их кнопки `>` скрывал общий стиль строки чата. Человек мог заполнить обе строки и ожидать, что ник станет участником комнаты. Enter при этом отправлял только форму поля в фокусе: свой ник давал «нельзя писать себе», а название комнаты создавало комнату только с владельцем.
Название комнаты не является адресом. Оно не уникально, сервер не даёт поиск комнат и возвращает пользователю только комнаты, где он уже состоит. Из-за сквозного шифрования новый участник получает доступ после того, как владелец добавит его по нику и раздаст новый ключ (ADR-018). Самостоятельно войти по одному открытому названию нельзя.
## Решение
- `#/new` показывает переключатель «личный чат / новая комната» и только одну форму за раз. У каждой формы есть видимая подпись, текстовая кнопка и своя строка ошибки. Начальное поле не фокусируется автоматически.
- Сценарий комнаты прямо называет её новой и приватной: сначала в ней только создатель; если владельцу другой комнаты нужен этот пользователь, владелец добавляет его, после чего комната сама появляется в списке.
- После создания открывается `#/room/<id>/members`, а не пустая лента. Создатель сразу видит себя владельцем и форму добавления людей.
- В шапке комнаты рядом с именем стоит подпись `· участники N` внутри той же кнопки. Число — размер состава, не присутствие онлайн; кнопка ведёт на экран участников.
## Последствия
Заполнить личный чат и комнату как одну составную форму больше нельзя. Кнопки действий видны и на десктопе, и на мобильном; Enter относится только к показанной форме.
Вход по имени, публичный каталог и ссылки-приглашения не добавляются. Такой сценарий потребует отдельного адреса или уникальности, правил одобрения и раздачи ключа; это не изменение текста формы.
+2 -2
View File
@@ -42,8 +42,8 @@ font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monos
## Компоновка
- Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px.
- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px. Нижнее поле подвала и строки ввода считается с `env(safe-area-inset-bottom)`: под системную полосу iPhone они не заходят (ADR-075).
- Сообщения — поток на обеих ширинах, автор и время над текстом; свои — у правого края ленты, чужие — у левого, ширина блока не больше `min(640px, 85%)` (ADR-065).
- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px. Нижнее поле подвала и строки ввода считается с `env(safe-area-inset-bottom)`: под системную полосу iPhone они не заходят. При открытой экранной клавиатуре её прямоугольник уже защищает край, поэтому обычные `20px + safe-area` строки ввода заменяются компактными 6 px (ADR-076).
- Сообщения — поток, автор и время над текстом; на телефоне и планшете свои — у правого края, чужие — у левого, на десктопе все — у левого; ширина блока не больше `min(640px, 85%)` (ADR-077).
- Ввод — рамка 1 px ink, без скруглений, `>` цветом mark слева.
- Активный элемент списка — инверсия: фон ink, текст bone.
- Разделители — 1 px `line`; разделитель «новые» — 1 px mark.
Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

+2 -1
View File
@@ -30,7 +30,7 @@
.author { color:var(--mute); font-size:12px; }
.author.me { color:var(--mark); }
.author .t { color:var(--stone); }
/* поток сообщений: свои — у правого края, чужие — у левого (ADR-065) */
/* поток сообщений: на touch своё справа, на десктопе всё слева (ADR-077) */
.msg { display:flex; flex-direction:column; gap:4px; max-width:min(640px, 85%); }
.msg.me { align-self:flex-end; align-items:flex-end; }
.msg p { margin:0; }
@@ -42,6 +42,7 @@
/* 2a — десктоп */
.desktop { width:1120px; height:700px; display:grid; grid-template-columns:224px 1fr; }
.desktop .msg.me { align-self:flex-start; align-items:flex-start; }
.side { border-right:1px solid var(--line); display:flex; flex-direction:column; }
.brand { height:64px; display:flex; align-items:center; gap:10px; padding:0 20px; border-bottom:1px solid var(--line); font-size:15px; letter-spacing:-0.02em; }
.list { padding:20px; display:flex; flex-direction:column; gap:2px; }
+1 -1
View File
@@ -5,4 +5,4 @@
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 и ADR-069 — полоса помощника форм над клавиатурой iOS, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец», ADR-067…072 и ADR-073…075 — версия в подвале, самообновление PWA, строка ввода, запрет масштабирования, код причины отказа пушей.
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 и ADR-069 — попытки убрать системную панель ввода iOS, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец», ADR-067…075 — версия в подвале, самообновление PWA, строка ввода, запрет масштабирования, код причины отказа пушей. ADR-076 фиксирует системное ограничение панели iOS и возврат к `textarea`, ADR-077 — стороны сообщений отдельно для touch и десктопа, ADR-078 — выбор между личным чатом и новой комнатой.
+12 -8
View File
@@ -8,7 +8,7 @@
Без inline-стилей и inline-скриптов (CSP). Рендер — `document.createElement` и `textContent`; `innerHTML` не используется нигде: сообщения — пользовательские данные.
Страница ведёт себя как приложение, а не как документ (ADR-075): масштабирования нет, резинового отскока нет, выделение снято с шапки, сайдбара, кнопок и разделителей — но не с текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара: их копируют. Установленное на «Домой» приложение объявлено мета-строками, имя на экране — `bare`, системная полоса остаётся над страницей. Подвал сайдбара и строка ввода отступают от нижней системной полосы iPhone.
Страница ведёт себя как приложение, а не как документ (ADR-075): масштабирования нет, резинового отскока нет, выделение снято с шапки, сайдбара, кнопок и разделителей — но не с текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара: их копируют. Установленное на «Домой» приложение объявлено мета-строками, имя на экране — `bare`, системная полоса остаётся над страницей. Подвал сайдбара и строка ввода отступают от нижней системной полосы iPhone; у строки весь нижний отступ становится 6 px, пока край уже закрыт экранной клавиатурой (ADR-076).
## Вход и регистрация
@@ -32,19 +32,23 @@
## Новый чат (`#/new`)
Две строки ввода: `@ник` → открыть личный чат; `#имя комнаты` → создать комнату. Ошибки: «такого ника нет», «нельзя писать себе».
Один экран с переключателем двух взаимоисключающих действий: «личный чат / новая комната». Видна только форма выбранного действия; начальный режим — личный чат, но поле само не получает фокус.
«Личный чат»: текст «напишите человеку по нику.», поле «ник человека» с подсказкой `@ник`, кнопка «написать». Ошибки: «такого ника нет», «нельзя писать себе».
«Новая комната»: текст «создайте новую приватную комнату. сначала в ней будете только вы; участников добавите следующим шагом.» и пояснение «если вас добавят в чужую комнату, она появится в списке сама.». Поле «название новой комнаты» с подсказкой `#название`, кнопка «создать комнату». Имя не ищет и не открывает существующую комнату: оно не уникально, а доступ даёт владелец добавлением по нику. После создания открывается экран участников новой комнаты (ADR-078).
## Чат
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн».
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. У комнаты в той же кнопке стоит подпись «· участники N»; это число состава, не присутствующих онлайн. Без темы и «N онлайн».
Лента: поток блоков сверху вниз, одинаковый на десктопе и мобильном; подряд идущие сообщения одного автора — без повтора автора над группой. Свои сообщения выровнены по правому краю ленты, чужие — по левому (ADR-065); свой ник в колонке автора при этом остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока выровнен по левому краю независимо от стороны. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
Лента: поток блоков сверху вниз; подряд идущие сообщения одного автора — без повтора автора над группой. На телефоне и планшете свои сообщения выровнены по правому краю, чужие — по левому; на десктопе все сообщения стоят слева (ADR-077). Десктоп определяется точным указателем с наведением, а не шириной окна: широкий планшет остаётся планшетом. Свой ник остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока всегда выровнен по левому краю. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; лишнее обрезается и при наборе, и при вставке: режется приходящее, набранное остаётся на месте (ADR-071).
Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; нативный `maxLength` не пускает лишнее. Тап по неинтерактивному месту истории снимает фокус со строки и закрывает клавиатуру. Пока открыта экранная клавиатура, весь нижний отступ строки заменяется 6 px; при физической клавиатуре и после закрытия обычный отступ сохраняется (ADR-076).
Строка сообщения — редактируемый блок, а не поле формы: над клавиатурой iOS рисует полосу помощника форм, и бывает она только у `input` и `textarea` (ADR-069). Блок принимает и отдаёт только текст: вставка приходит текстом, значение читается и пишется `textContent`, `innerHTML` не появляется. Подсказку рисует правило CSS для пустого блока. Растёт под содержимое, дальше семи строк — прокрутка. Заблокированный ввод перестаёт быть редактируемым. Остальные строки ввода — вход, регистрация, смена пароля, «новый чат», участники — остаются полями формы: там нужны менеджеры паролей, а полоса на разовом экране не мешает.
Строка сообщения — `textarea` (ADR-076): она принимает только текст, сама держит вставку, выделение, отмену, подсказку, предел и состояние `disabled`. Растёт под содержимое, дальше семи строк — прокрутка. Системную панель со стрелками и «готово» iOS рисует поверх клавиатуры и для `textarea`, и для `contenteditable`; у веб-страницы нет поддерживаемого API, чтобы её скрыть. Полное управление этой панелью потребовало бы отдельной нативной iOS-оболочки; собственная экранная клавиатура в веб-клиенте не делается.
Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038).
@@ -62,7 +66,7 @@
## Участники (`#/room/<id>/members`)
Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
Заголовок «участники» и список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
## Настройки (`#/settings`)
@@ -118,6 +122,6 @@
Семантика: `nav`, `main`, `form`, `button`, `ul/li` для списков; `aria-live="polite"` на ленте; фокус в строку ввода при открытии чата на десктопе; контраст ink/bone и mark/bone не ниже 4.5:1; цели нажатия на мобильном не меньше 44 px.
Строка сообщения — редактируемый блок (ADR-069), и семантику ему задают руками: `role="textbox"`, `aria-multiline="true"`, подпись `aria-label` тем же текстом, что подсказка. Заблокированный ввод перестаёт быть редактируемым и говорит об этом `aria-disabled`.
Строка сообщения — нативная `textarea` (ADR-076): роль многострочного поля и состояние `disabled` сообщает сам браузер; доступное имя повторяет текст видимой подсказки через `aria-label`, кнопка `>` объявляется как «отправить».
Масштабирование запрещено — и щипок, и двойной тап (ADR-075). Это сознательный размен: интерфейс ведёт себя как приложение, а человек, которому нужно увеличить мелкое, средства лишается. Размеры шрифтов от запрета не меняются: поля ввода остаются 14 px, как в `docs/identity/brief.md`. Браузер вправе запрет проигнорировать.
+74 -18
View File
@@ -306,6 +306,36 @@ input[type="password"] {
font-size: 13px;
}
/* новый чат — одно действие за раз (ADR-078). Подсказки стоят до формы,
чтобы смысл выбора был понятен до фокуса в поле */
.new {
max-width: 460px;
}
.new__tabs {
margin-bottom: 18px;
}
.new__panel[hidden] {
display: none;
}
.new__copy {
max-width: 420px;
margin: 0 0 18px;
color: var(--text2);
font-size: 12px;
line-height: 1.6;
}
.new__aside {
display: block;
margin-top: 8px;
color: var(--mute);
font-size: 11px;
}
/* каркас: сайдбар и экран */
.shell {
@@ -717,6 +747,9 @@ input[type="password"] {
/* чат: шапка, лента, ввод — docs/identity/screens.html */
.chat-title {
display: flex;
align-items: center;
gap: 8px;
padding: 0;
border: 0;
border-radius: 0;
@@ -728,6 +761,19 @@ input[type="password"] {
cursor: pointer;
}
.chat-title__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title__members {
flex: none;
color: var(--mute);
font-size: 11px;
}
/* лента прижата к низу: короткая переписка не висит под шапкой */
.feed {
@@ -752,9 +798,8 @@ input[type="password"] {
margin-top: 0;
}
/* строка сообщения: автор над текстом, блок целиком к своему краю —
свои вправо, чужие влево (ADR-065). Ширина блока ограничена, иначе
выравнивать нечего — текст в один абзац занял бы всю ленту. */
/* строка сообщения: автор над текстом. На телефоне и планшете своё стоит
у правого края; на десктопе все сообщения идут слева (ADR-077). */
.line {
display: flex;
@@ -773,6 +818,17 @@ input[type="password"] {
align-items: flex-end;
}
/* Ширина окна не отличает планшет от десктопа: iPad бывает шире ноутбука.
Точный указатель с hover — признак десктопного способа работы; там сторона
не помогает и только заставляет взгляд ходить через всю ленту. */
@media (hover: hover) and (pointer: fine) {
.line--me {
align-self: flex-start;
align-items: flex-start;
}
}
.author {
display: flex;
flex-wrap: wrap;
@@ -875,6 +931,14 @@ input[type="password"] {
padding: 0 16px calc(20px + env(safe-area-inset-bottom));
}
/* При открытой экранной клавиатуре нижний край уже закрыт её прямоугольной
поверхностью. Safari продолжает отдавать safe-area домашнего индикатора;
заменяем весь нижний отступ на компактные 6 px (ADR-076). */
.compose.is-keyboard {
padding-bottom: 6px;
}
.input {
display: flex;
align-items: center;
@@ -918,12 +982,12 @@ input[type="password"] {
line-height: 1.5;
}
/* строка сообщения — редактируемый блок, а не поле формы (ADR-069):
растёт под текст сама, дальше семи строк — прокрутка. Переносы строк
в ней настоящие символы, поэтому pre-wrap; длинное слово рвётся,
иначе строка ввода уезжает вбок */
/* строка сообщения — textarea (ADR-076): браузер сам держит вставку,
выделение, отмену и предел. Число rows растёт под текст до семи строк,
дальше поле прокручивается */
.input .input__field--text {
resize: none;
overflow-y: auto;
/* семь строк — это семь высот строки плюс вертикальные отступы:
без них предел вышел бы вдвое ниже обещанного */
@@ -932,14 +996,6 @@ input[type="password"] {
overflow-wrap: anywhere;
}
/* подсказка пустой строки: placeholder у блока не бывает, текст приходит
атрибутом data-hint (ADR-069) */
.input .input__field--text:empty::before {
content: attr(data-hint);
color: var(--stone);
}
.input .input__field::placeholder {
color: var(--stone);
}
@@ -1045,9 +1101,9 @@ input[type="password"] {
--field-pad: 13px;
}
/* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в
строке `@ник`: в рамке ввода «>» остаётся один, слева
(docs/identity/brief.md, «Компоновка») */
/* на десктопе чат отправляет enter — кнопка `>` не нужна: в рамке
ввода она остаётся одна, слева (docs/identity/brief.md, «Компоновка»).
Остальные формы используют обычные текстовые `.button` */
.input__send {
display: none;
-10
View File
@@ -330,11 +330,6 @@ function apply() {
// Набранным считается только то, куда набирают: textarea и поля из
// TYPED. Чекбокс, файл, скрытое поле и кнопка непусты сами по себе,
// а обновление они бы запретили насовсем.
//
// Строка сообщения в чате — редактируемый блок, а не поле формы
// (ADR-069): value у него нет, набранное лежит в textContent. Считается
// и закрытый блок: ввод бывает заблокирован предупреждением о ключе,
// а недописанное в нём остаётся.
function typed() {
for (const node of document.querySelectorAll("input, textarea")) {
if (node.value === "") {
@@ -344,11 +339,6 @@ function typed() {
return true;
}
}
for (const node of document.querySelectorAll("[contenteditable]")) {
if (node.textContent !== "") {
return true;
}
}
return false;
}
+161 -280
View File
@@ -9,6 +9,11 @@ import { DESKTOP, clear, el, wide } from "./dom.js";
// Предел текста и порог счётчика — docs/ui.md, «Чат».
const LIMIT = 4000;
const COUNTER_AT = 3500;
const COMPOSER_ROWS = 7;
// Экранная клавиатура уменьшает visual viewport на сотни пикселей. Порог
// отсекает движение панелей браузера; доля высоты работает и на iPad.
const KEYBOARD_SHRINK = 120;
const KEYBOARD_SHRINK_RATIO = 0.18;
// Разделители дат: на десктопе — полная дата, на мобильном — короткая,
// как в эталоне. Время — ЧЧ:ММ в локальной зоне.
@@ -49,10 +54,6 @@ export function renderChat(root, ctx, chatId) {
roomId: sync.roomIdOf(chatId),
limit: ctx.config?.maxMessageChars ?? LIMIT,
alive: true,
// Каким значением открыт редактируемый блок строки ввода:
// "plaintext-only" или "true" (ADR-069). От него зависит, разбирает ли
// браузер вставку и перенос строки сам или это делаем мы.
edit: "true",
// Имя комнаты; до чтения записи чата вместо него идентификатор,
// как в db.blankChat.
name: sync.roomIdOf(chatId),
@@ -94,7 +95,20 @@ export function renderChat(root, ctx, chatId) {
});
root.append(view.feed);
root.append(composer(view));
view.compose = composer(view);
root.append(view.compose);
sizeField(view);
const offKeyboard = watchKeyboard(view);
// Обычный тап по истории закрывает системную клавиатуру. Интерактивные
// элементы ленты (сейчас это «повторить») ведут своё действие сами:
// подменять их на жест закрытия клавиатуры нельзя (ADR-076).
view.feed.addEventListener("click", (event) => {
const target = event.target;
if (target instanceof Element && target.closest("button, a, [role=button]") !== null) {
return;
}
view.field.blur();
});
const offMessages = sync.on("messages", (detail) => {
if (detail.chatId === view.chatId) {
@@ -117,6 +131,10 @@ export function renderChat(root, ctx, chatId) {
const media = matchMedia(DESKTOP);
const onMedia = () => paint(view, true);
media.addEventListener("change", onMedia);
// Переносы зависят от ширины поля. Поворот телефона и изменение окна
// пересчитывают число строк даже без нового ввода (ADR-076).
const onResize = () => sizeField(view);
window.addEventListener("resize", onResize);
run(view, () => load(view));
@@ -126,7 +144,9 @@ export function renderChat(root, ctx, chatId) {
offNet();
offPeers();
offRooms();
offKeyboard();
media.removeEventListener("change", onMedia);
window.removeEventListener("resize", onResize);
};
}
@@ -147,11 +167,21 @@ function head(view) {
const back = el("button", "back back--chat", "назад");
back.type = "button";
back.addEventListener("click", () => view.ctx.go("#/"));
view.title = el("button", "chat-title", titleText(view));
view.title = el("button", "chat-title");
view.title.type = "button";
view.title.addEventListener("click", () => view.ctx.go(view.roomId !== null
? `#/room/${view.roomId}/members`
: `#/contact/${view.peer}`));
if (view.roomId !== null) {
// Состав был спрятан за неочевидным нажатием на имя. Одна составная
// кнопка сохраняет место в мобильной шапке: имя сокращается, а смысл
// действия и число участников остаются видны (ADR-078).
view.titleName = el("span", "chat-title__name", titleText(view));
view.titleMembers = el("span", "chat-title__members", "· участники");
view.title.append(view.titleName, view.titleMembers);
} else {
view.title.textContent = titleText(view);
}
bar.append(back, view.title);
return bar;
}
@@ -188,7 +218,16 @@ async function refreshRoom(view, known) {
return;
}
view.name = record?.title || view.roomId;
view.title.textContent = titleText(view);
const title = titleText(view);
view.titleName.textContent = title;
const count = record?.hidden === true || !Array.isArray(record?.members)
? null
: record.members.length;
view.titleMembers.textContent = count === null ? "· участники" : `· участники ${count}`;
view.title.setAttribute(
"aria-label",
count === null ? `${title}, открыть участников` : `${title}, открыть участников: ${count}`,
);
hint(view, `сообщение в #${shortName(view.name)}`);
// Скрытая запись комнаты — это room_left или собственный выход:
// отправлять больше некуда, и сервер ответил бы not_member.
@@ -201,10 +240,10 @@ async function refreshRoom(view, known) {
// цветом mark. Enter отправляет только на десктопе; на мобильном он делает
// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»).
//
// Строка сообщения — редактируемый блок, а не поле формы (ADR-069): над
// клавиатурой iOS рисует полосу помощника форм, и бывает она только
// у input и textarea. Со страницы её не убрать, а в чате она занимает место
// и ничего не делает: поле на экране одно, переходить стрелками некуда.
// Строка сообщения — textarea (ADR-076). Проверка на реальном iPhone
// показала, что системная панель WebKit бывает и у contenteditable, а
// поддерживаемого веб-API для неё нет. Нативное поле зато само держит
// обычное редактирование, вставку, предел и доступность.
function composer(view) {
const form = el("div", "compose");
form.setAttribute("role", "form");
@@ -217,13 +256,11 @@ function composer(view) {
const prompt = el("span", "p", ">");
prompt.setAttribute("aria-hidden", "true");
view.field = el("div", "input__field input__field--text");
view.edit = editing(view.field);
// Поле ввода без input: экранному диктору о нём говорят роль и подпись,
// подпись же стоит подсказкой в пустом блоке (docs/ui.md, «Доступность»).
view.field.setAttribute("role", "textbox");
view.field.setAttribute("aria-multiline", "true");
view.field = el("textarea", "input__field input__field--text");
view.field.rows = 1;
hint(view, "сообщение");
view.field.maxLength = view.limit;
view.field.autocomplete = "off";
// Клавиша Enter на мобильном переносит строку, а не отправляет: отправка
// там на кнопке «>» (docs/ui.md, «Чат»). Поэтому «enter», а не «send»:
// подпись клавиши обещает то, что клавиша делает.
@@ -239,301 +276,145 @@ function composer(view) {
view.send = el("button", "input__send", ">");
view.send.type = "button";
view.send.setAttribute("aria-label", "отправить");
view.send.addEventListener("click", () => submit(view));
row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send);
form.append(view.bar, row);
// Предел держится до вставки, а не после: обрезается приходящее,
// а набранное остаётся на месте (ADR-071).
view.field.addEventListener("beforeinput", (event) => cap(view, event));
view.field.addEventListener("input", (event) => {
if (event.isComposing) {
// Пока идёт композиция IME, содержимое не трогаем: правка оборвала
// бы её на полуслове, а подтверждённое не попало бы в блок вовсе.
// Подтверждённое разберёт compositionend (ADR-071).
return;
}
fit(view);
tail(view);
count(view);
});
view.field.addEventListener("compositionend", () => {
fit(view);
tail(view);
view.field.addEventListener("input", () => {
sizeField(view);
count(view);
});
view.field.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.isComposing) {
if (event.key !== "Enter" || event.shiftKey || event.isComposing) {
return;
}
if (wide() && !event.shiftKey) {
event.preventDefault();
submit(view);
if (!wide()) {
return;
}
if (view.edit !== "plaintext-only") {
// Блок без plaintext-only ставит на Enter <div> или <br>, а значение
// читается textContent: перенос вписываем сами (ADR-069).
event.preventDefault();
insert(view, "\n");
}
event.preventDefault();
submit(view);
});
if (view.edit !== "plaintext-only") {
// plaintext-only приносит из буфера и мыши только текст сам. Без него
// в блок приезжает чужая разметка, поэтому вставка и перенос — наши.
view.field.addEventListener("paste", (event) => {
event.preventDefault();
insert(view, event.clipboardData?.getData("text/plain") ?? "");
});
view.field.addEventListener("drop", (event) => {
event.preventDefault();
insert(view, event.dataTransfer?.getData("text/plain") ?? "");
});
// Перетаскивание изнутри блока браузер сделал бы переносом: вписал бы
// текст на новом месте и убрал со старого. Вставку мы делаем сами,
// отменяя действие по умолчанию, — уносить исходное браузеру нечем,
// и перенос молча оборачивался бы удвоением. Объявляем копирование:
// тогда это честная копия (ADR-071).
view.field.addEventListener("dragstart", (event) => {
if (event.dataTransfer !== null) {
event.dataTransfer.effectAllowed = "copy";
}
});
}
return form;
}
// editing включает редактирование блока и отдаёт значение, которое
// применилось. Нужен plaintext-only: в нём браузер кладёт в блок только
// текст, чем бы ни был буфер обмена, и разметке взяться неоткуда.
//
// Знают его не все браузеры, и незнакомое значение атрибута делает блок
// нередактируемым вовсе — то есть строка ввода перестала бы работать молча.
// Поэтому оно не назначается, а проверяется чтением: не применилось —
// остаётся "true", а вставку, перетаскивание и перенос строки разбирает
// composer (ADR-069).
function editing(field) {
try {
field.contentEditable = "plaintext-only";
} catch {
// Браузер, которому это значение незнакомо, бросает SyntaxError.
}
if (field.contentEditable === "plaintext-only") {
return "plaintext-only";
}
field.contentEditable = "true";
return "true";
}
// hint — подсказка в пустой строке ввода (docs/ui.md, «Чат»). placeholder
// у редактируемого блока не бывает: текст кладётся атрибутом, а рисует его
// правило CSS через :empty::before. Тот же текст — подпись для экранного
// диктора: другой у строки ввода нет.
// hint держит видимую подсказку и доступное имя поля одинаковыми. Один
// placeholder не заменяет подпись для всех экранных дикторов (ADR-076).
function hint(view, text) {
view.field.dataset.hint = text;
view.field.placeholder = text;
view.field.setAttribute("aria-label", text);
}
// allow открывает и закрывает ввод: предупреждение о ключе и уход
// из комнаты гасят и блок, и кнопку «>» (ADR-016, ADR-044). У блока нет
// disabled — закрытый перестаёт быть редактируемым и говорит об этом
// экранному диктору.
function allow(view, on) {
view.field.contentEditable = on ? view.edit : "false";
if (on) {
view.field.removeAttribute("aria-disabled");
} else {
view.field.setAttribute("aria-disabled", "true");
// sizeField растит textarea до семи фактических строк. field-sizing появился
// в Safari позже самого приложения, поэтому высоту держит нативный rows:
// сначала даём полю одну строку, читаем полную scrollHeight с переносами и
// выставляем нужное число. Атрибут не создаёт запрещённого inline-стиля.
function sizeField(view) {
const field = view.field;
field.rows = 1;
const style = getComputedStyle(field);
const line = Number.parseFloat(style.lineHeight);
const padding = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
if (!Number.isFinite(line) || line <= 0 || !Number.isFinite(padding)) {
return;
}
const rows = Math.ceil((field.scrollHeight - padding) / line);
field.rows = Math.max(1, Math.min(COMPOSER_ROWS, rows));
}
// watchKeyboard снимает нижний safe-area только по консервативной эвристике
// экранной клавиатуры. На iOS env(safe-area-inset-bottom) ошибочно остаётся
// прежним и кладёт под composer ещё одну пустую полосу (ADR-076).
//
// Одного focus недостаточно: поле бывает сфокусировано с физической
// клавиатурой. Экранную выдаёт уменьшившийся visual viewport при масштабе 1;
// pinch-зум, если Safari всё-таки его пропустил, за клавиатуру не считается.
function watchKeyboard(view) {
const viewport = window.visualViewport;
let frame = 0;
let focusWidth = 0;
const update = () => {
frame = 0;
const page = document.documentElement;
const threshold = Math.max(KEYBOARD_SHRINK, page.clientHeight * KEYBOARD_SHRINK_RATIO);
const shrunk = viewport !== null
&& viewport !== undefined
&& Math.abs(viewport.scale - 1) < 0.01
// При повороте width обновляется у двух viewport не одновременно.
// До стабилизации размеров не принимаем этот переход за клавиатуру.
&& Math.abs(page.clientWidth - viewport.width) < 2
// WebKit иногда оставляет старую высоту visual viewport даже после
// поворота. Смена ширины после фокуса безопасно выключает компенсацию
// до следующего тапа по полю.
&& Math.abs(page.clientWidth - focusWidth) < 2
&& page.clientHeight - viewport.height > threshold;
view.compose.classList.toggle("is-keyboard", document.activeElement === view.field && shrunk);
};
const schedule = () => {
if (frame !== 0) cancelAnimationFrame(frame);
frame = requestAnimationFrame(update);
};
const arm = () => {
focusWidth = document.documentElement.clientWidth;
schedule();
};
// На blur не полагаемся на порядок обновления activeElement в WebKit:
// класс должен исчезнуть сразу при закрытии клавиатуры в любом случае.
const blur = () => {
if (frame !== 0) cancelAnimationFrame(frame);
frame = 0;
focusWidth = 0;
view.compose.classList.remove("is-keyboard");
};
// После ухода приложения в фон WebKit может вернуть старую высоту.
// Не рискуем краем экрана: компактный отступ включится снова по тапу.
const visibility = () => {
if (document.hidden) {
blur();
return;
}
schedule();
};
view.field.addEventListener("pointerdown", arm);
view.field.addEventListener("focus", arm);
view.field.addEventListener("blur", blur);
viewport?.addEventListener("resize", schedule);
window.addEventListener("resize", schedule);
document.addEventListener("visibilitychange", visibility);
schedule();
return () => {
if (frame !== 0) cancelAnimationFrame(frame);
view.field.removeEventListener("pointerdown", arm);
view.field.removeEventListener("focus", arm);
view.field.removeEventListener("blur", blur);
viewport?.removeEventListener("resize", schedule);
window.removeEventListener("resize", schedule);
document.removeEventListener("visibilitychange", visibility);
};
}
// allow открывает и закрывает ввод: предупреждение о ключе и уход
// из комнаты гасят и поле, и кнопку «>» (ADR-016, ADR-044).
function allow(view, on) {
view.field.disabled = !on;
view.send.disabled = !on;
}
// cap держит предел до вставки: обрезается то, что приходит в блок,
// а набранное остаётся на месте — так же, как считал maxLength у textarea
// (ADR-071). Влезающее браузер вставляет сам: тогда и отмена (cmd+z)
// остаётся его, а не нашей.
//
// Композицию IME не трогаем вовсе: отменённая на полуслове, она уносит
// с собой и подтверждённый ввод. Лишнее в ней снимет fit на compositionend.
function cap(view, event) {
if (event.isComposing || event.inputType === "insertCompositionText") {
return;
}
if (!event.inputType.startsWith("insert")) {
return;
}
// Перенос строки и прочее без текста считаем одним символом: тогда cap
// вмешивается только тогда, когда места не осталось совсем.
const text = event.data ?? event.dataTransfer?.getData("text/plain") ?? "";
const range = target(view, event);
if ((text === "" ? 1 : text.length) <= free(view, range)) {
return;
}
event.preventDefault();
insert(view, text, range);
}
// target — куда пойдёт вставка: диапазон, который браузер собирается
// заменить (beforeinput знает его точно — это не всегда выделение:
// вставка мышью идёт туда, куда отпустили), иначе выделение в блоке.
function target(view, event = null) {
const ranges = event?.getTargetRanges?.() ?? [];
if (ranges.length > 0) {
const range = document.createRange();
range.setStart(ranges[0].startContainer, ranges[0].startOffset);
range.setEnd(ranges[0].endContainer, ranges[0].endOffset);
return view.field.contains(range.commonAncestorContainer) ? range : null;
}
const selection = getSelection();
const now = selection !== null && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
return now !== null && view.field.contains(now.commonAncestorContainer) ? now : null;
}
// free — сколько символов ещё влезет туда, куда идёт вставка: предел минус
// то, что останется от набранного, когда заменяемое уйдёт.
function free(view, range) {
return view.limit - (value(view).length - (range === null ? 0 : range.toString().length));
}
// value — набранное так, как оно уедет собеседнику. В plaintext-only
// браузер держит последнюю строку вторым «\n» в самом конце: в textContent
// он виден, а в сообщении его нет — его снимает trim в sync.send. Считаем
// и режем по тому же, что отправляем (ADR-071). В запасном пути последнюю
// строку держит <br>, которого в textContent нет вовсе, и перенос в конце
// там настоящий.
function value(view) {
const text = view.field.textContent;
return view.edit === "plaintext-only" && text.endsWith("\n") ? text.slice(0, -1) : text;
}
// cut режет текст по пределу, не разрывая суррогатную пару: половина пары
// уехала бы собеседнику битым символом.
function cut(text, limit) {
if (limit <= 0) {
return "";
}
if (text.length <= limit) {
return text;
}
const last = text.charCodeAt(limit - 1);
return text.slice(0, last >= 0xd800 && last <= 0xdbff ? limit - 1 : limit);
}
// fit — последняя страховка предела (docs/ui.md, «Чат»): лишнее в блок
// попадает мимо cap — композицией IME или вставкой, о которой браузер
// не рассказал. Снимается ровно хвост сверх предела, а не переписывается
// блок целиком: правка узлов оставляет курсор на месте и не сносит стек
// отмены. Опустевший блок остаётся без узлов: подсказка стоит правилом
// :empty, а браузер оставляет в опустевшем блоке <br>, и с ним подсказки
// не видно.
function fit(view) {
const raw = view.field.textContent;
const text = value(view);
const keep = cut(text, view.limit);
if (keep.length < text.length) {
// Вместе с лишним уходит и заполнитель последней строки, если он есть:
// он в самом конце, а браузер ставит его снова, когда понадобится.
trim(view.field, raw.length - keep.length);
return;
}
if (raw === "" && view.field.firstChild !== null) {
clear(view.field);
}
}
// trim снимает с хвоста блока лишние единицы текста, не трогая остального:
// textContent = … переписал бы блок целиком и унёс бы и курсор, и отмену.
function trim(field, extra) {
const walker = document.createTreeWalker(field, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode() !== null) {
nodes.push(walker.currentNode);
}
let left = extra;
for (let i = nodes.length - 1; i >= 0 && left > 0; i -= 1) {
const node = nodes[i];
const take = Math.min(left, node.length);
node.deleteData(node.length - take, take);
left -= take;
}
}
// insert вписывает текст туда, куда идёт вставка, обрезая его по свободному
// месту. Своими руками, а не execCommand: тот в Chrome разбирает перенос
// строки в <div>, а в блоке живёт только текст.
function insert(view, text, where) {
// where — диапазон, который назвал beforeinput. Его не передали — берём
// выделение; выделения в блоке нет — вписываем в конец.
const range = where === undefined ? target(view) : where;
const fitted = cut(text, free(view, range));
if (fitted === "") {
return;
}
const node = document.createTextNode(fitted);
const selection = getSelection();
if (range === null) {
view.field.append(node);
} else {
range.deleteContents();
range.insertNode(node);
}
if (selection !== null) {
const at = document.createRange();
at.setStartAfter(node);
at.collapse(true);
selection.removeAllRanges();
selection.addRange(at);
}
// Вставка делит текст на два-три соседних узла; курсор normalize
// переставляет сам.
view.field.normalize();
// Своё изменение блока события input не порождает: счётчик, предел
// и последнюю строку трогаем руками.
fit(view);
tail(view);
count(view);
}
// tail держит последнюю пустую строку видимой. Перенос в самом конце
// браузер не рисует — строки после него нет, — и курсор оставался бы
// на прежней строке, а набранное дальше уезжало бы перед переносом.
// Пустую строку держит <br>: тот самый заполнитель, который браузер сам
// ставит в опустевший блок. В textContent его нет, и на отправляемый текст
// он не влияет.
//
// В plaintext-only это не нужно: там последнюю строку браузер ведёт сам,
// своим переносом, и лишний заполнитель дал бы вторую пустую строку.
function tail(view) {
if (view.edit === "plaintext-only") {
return;
}
const last = view.field.lastChild;
const wants = view.field.textContent.endsWith("\n");
if (wants && (last === null || last.nodeName !== "BR")) {
view.field.append(document.createElement("br"));
return;
}
if (!wants && last !== null && last.nodeName === "BR") {
last.remove();
}
}
// count — счётчик остатка: появляется после порога (docs/ui.md, «Чат»).
// Считается то, что уедет: заполнитель последней строки в счёт не идёт
// (ADR-071).
function count(view) {
const length = value(view).length;
const length = view.field.value.length;
view.counter.textContent = String(view.limit - length);
view.counter.hidden = length <= COUNTER_AT;
}
function submit(view) {
const text = view.field.textContent;
const text = view.field.value;
if (view.blocked || view.gone || text.trim() === "") {
return;
}
view.field.textContent = "";
view.field.value = "";
sizeField(view);
count(view);
run(view, () => sync.send(view.chatId, text));
}
@@ -804,8 +685,8 @@ function line(view, record, previous, parent = view.body) {
view.nodes.set(record.id, { node, marks });
}
// lineClass — своя сторона ленты: свои сообщения вправо, чужие влево
// (ADR-065), классом на самой строке — выравнивает feed-list.
// lineClass отмечает свои сообщения отдельным классом. На какой стороне он
// окажется, CSS решает по основному указателю устройства (ADR-077).
function lineClass(record, me, first) {
const classes = ["line"];
if (record.from === me) {
+1 -1
View File
@@ -32,7 +32,7 @@ export function renderMembers(root, ctx, roomId) {
const people = el("section", "block block--first");
view.list = el("ul", "members");
view.note = message();
people.append(view.list, add(view), view.note);
people.append(el("h2", "section", "участники"), view.list, add(view), view.note);
body.append(view.warn, people, exit(view));
root.append(body);
+113 -48
View File
@@ -1,5 +1,5 @@
// Новый чат — docs/ui.md, «Новый чат». Две строки ввода: `@ник` открывает
// личный чат, `#имя комнаты` заводит комнату.
// Новый чат — docs/ui.md, «Новый чат». Это выбор одного действия:
// личный чат по нику или новая приватная комната (ADR-078).
import * as sync from "../sync.js";
import { el, message, setError, setNote } from "./dom.js";
@@ -8,99 +8,164 @@ import { el, message, setError, setNote } from "./dom.js";
// единицы UTF-16, а сервер — руны: за предел это не выпустит.
const ROOM_NAME_MAX = 64;
const MODES = [
["dm", "личный чат"],
["room", "новая комната"],
];
export function renderNew(root, ctx) {
root.append(head(ctx));
const body = el("div", "body");
// Место под ошибку одно на оба поля: строка состояния у экрана одна
// (ADR-028).
const body = el("div", "body new");
const dm = direct(ctx);
const room = roomForm(ctx);
const panels = { dm, room };
let mode = "dm";
const tabs = el("div", "tabs new__tabs");
tabs.setAttribute("role", "group");
tabs.setAttribute("aria-label", "что открыть");
const buttons = new Map();
const select = (next, focus = false) => {
mode = next;
for (const [name, panel] of Object.entries(panels)) {
const on = name === mode;
panel.root.hidden = !on;
const tab = buttons.get(name);
tab.classList.toggle("is-on", on);
tab.setAttribute("aria-pressed", String(on));
}
if (focus) {
panels[mode].field.focus();
}
};
MODES.forEach(([name, text], index) => {
if (index > 0) {
const sep = el("span", "tabs__sep", "/");
sep.setAttribute("aria-hidden", "true");
tabs.append(sep);
}
const tab = el("button", "tab", text);
tab.type = "button";
tab.addEventListener("click", () => select(name, true));
buttons.set(name, tab);
tabs.append(tab);
});
body.append(tabs, dm.root, room.root);
root.append(body);
select(mode);
}
function direct(ctx) {
const panel = section("напишите человеку по нику.");
const input = field("ник человека", "@ник");
const go = el("button", "button", "написать");
go.type = "submit";
const note = message();
panel.form.append(input.wrap, go, note);
const dm = row("@ник");
const room = row("#имя комнаты");
room.field.maxLength = ROOM_NAME_MAX;
dm.form.addEventListener("submit", async (event) => {
panel.form.addEventListener("submit", async (event) => {
event.preventDefault();
if (dm.go.disabled) {
if (go.disabled) {
return;
}
setNote(note, "");
// Ник вводят как в списке: с «@» или без. Регистр не хранится —
// ники строчные (ADR-019).
const nick = dm.field.value.trim().replace(/^@/, "").toLowerCase();
const nick = input.field.value.trim().replace(/^@/, "").toLowerCase();
if (nick === "") {
dm.field.focus();
input.field.focus();
return;
}
dm.field.value = nick;
dm.go.disabled = true;
input.field.value = nick;
go.disabled = true;
try {
await sync.openDm(nick);
ctx.go(`#/dm/${nick}`);
} catch (err) {
setError(note, ctx.errorText(err));
dm.field.focus();
input.field.focus();
} finally {
dm.go.disabled = false;
go.disabled = false;
}
});
room.form.addEventListener("submit", async (event) => {
return { ...panel, field: input.field };
}
function roomForm(ctx) {
const panel = section(
"создайте новую приватную комнату. сначала в ней будете только вы; участников добавите следующим шагом.",
);
panel.copy.append(el(
"span",
"new__aside",
"если вас добавят в чужую комнату, она появится в списке сама.",
));
const input = field("название новой комнаты", "#название");
input.field.maxLength = ROOM_NAME_MAX;
const go = el("button", "button", "создать комнату");
go.type = "submit";
const note = message();
panel.form.append(input.wrap, go, note);
panel.form.addEventListener("submit", async (event) => {
event.preventDefault();
if (room.go.disabled) {
if (go.disabled) {
return;
}
setNote(note, "");
// Имя вводят как в списке: с «#» или без. Регистр имени комнаты
// сохраняется — это её название, а не идентификатор.
const name = room.field.value.trim().replace(/^#/, "").trim();
const name = input.field.value.trim().replace(/^#/, "").trim();
if (name === "") {
room.field.focus();
input.field.focus();
return;
}
room.go.disabled = true;
go.disabled = true;
try {
const chatId = await sync.createRoom(name);
if (chatId === null) {
room.field.focus();
input.field.focus();
return;
}
ctx.go(`#/room/${sync.roomIdOf(chatId)}`);
// Новая комната начинается с одного владельца. Первый полезный
// экран — её состав: там сразу видны владелец и добавление людей.
ctx.go(`#/room/${sync.roomIdOf(chatId)}/members`);
} catch (err) {
setError(note, ctx.errorText(err));
room.field.focus();
input.field.focus();
} finally {
room.go.disabled = false;
go.disabled = false;
}
});
body.append(dm.form, room.form, note);
root.append(body);
dm.field.focus();
return { ...panel, field: input.field };
}
// row — строка ввода в стиле чата: рамка 1 px ink, слева «>» цветом mark
// (docs/identity/brief.md, «Компоновка»).
function row(placeholder) {
const form = el("form", "form form--row");
function section(text) {
const root = el("section", "new__panel");
const copy = el("p", "new__copy", text);
const form = el("form", "form");
form.noValidate = true;
root.append(copy, form);
return { root, copy, form };
}
const line = el("div", "input");
const prompt = el("span", "p", ">");
prompt.setAttribute("aria-hidden", "true");
const field = el("input", "input__field");
field.type = "text";
field.placeholder = placeholder;
field.autocapitalize = "off";
field.autocomplete = "off";
field.spellcheck = false;
const go = el("button", "input__send", ">");
go.type = "submit";
line.append(prompt, field, go);
form.append(line);
return { form, field, go };
function field(label, placeholder) {
const wrap = el("label", "field");
wrap.append(el("span", null, label));
const input = el("input");
input.type = "text";
input.placeholder = placeholder;
input.autocapitalize = "off";
input.autocomplete = "off";
input.spellcheck = false;
wrap.append(input);
return { wrap, field: input };
}
function head(ctx) {
+1 -5
View File
@@ -59,11 +59,7 @@ function tap(event) {
// дважды подряд — обычное дело. Цена мала: до элементов управления зум
// по двойному тапу не доходит и без страховки — `touch-action:
// manipulation` стоит на документе.
//
// Строка сообщения — редактируемый блок, а не textarea (ADR-069), и она
// в этом перечне: погашенный тап не доносит до неё ни click, ни фокус,
// то есть второй тап подряд не ставил бы курсор в поле ввода.
function control(target) {
return typeof target?.closest === "function"
&& target.closest("button, input, textarea, select, label, a, [contenteditable]") !== null;
&& target.closest("button, input, textarea, select, label, a") !== null;
}
+1 -1
View File
@@ -7,7 +7,7 @@
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
const VERSION = "v6";
const VERSION = "v8";
const CACHE = `bare-${VERSION}`;
// Оболочка — всё, из чего клиент поднимается без сети. Список явный: