Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e31889f11 | ||
|
|
d0a7fab09c | ||
|
|
3a30cc63d3 | ||
|
|
a5955030bb | ||
|
|
d80078b2df | ||
|
|
c5f2ac26c6 | ||
|
|
1a56d2f8a1 | ||
|
|
d7624dfade | ||
|
|
547637067d | ||
|
|
f9ac83bef8 | ||
|
|
35c63194d1 | ||
|
|
7bb0418a68 | ||
|
|
ba58643352 | ||
|
|
9935acb86d | ||
|
|
334ce95935 | ||
|
|
c826d581ea | ||
|
|
383e374f0f | ||
|
|
cfd0ec0ce9 | ||
|
|
522ba89e36 |
@@ -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)
|
||||
|
||||
+14
-30
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// bare serve запустить http-сервер
|
||||
// bare vapid напечатать пару vapid-ключей
|
||||
// bare version напечатать ревизию сборки
|
||||
// bare version напечатать ревизию и время коммита
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/api"
|
||||
"github.com/xmatic-squad/bare/internal/build"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
"github.com/xmatic-squad/bare/internal/web"
|
||||
@@ -56,7 +56,7 @@ func usage() {
|
||||
использование:
|
||||
bare serve запустить http-сервер
|
||||
bare vapid напечатать пару vapid-ключей
|
||||
bare version напечатать ревизию сборки
|
||||
bare version напечатать ревизию и время коммита
|
||||
|
||||
настройка — переменные окружения BARE_*, см. docs/deploy.md
|
||||
`)
|
||||
@@ -159,33 +159,17 @@ func vapid() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// version печатает, какой код собран в этот бинарь: короткую ревизию
|
||||
// и время коммита. Время коммита, а не компиляции: штамп момента сборки
|
||||
// делал бы каждую пересборку одного коммита новым файлом, и сверка хеша
|
||||
// со сборкой из тега перестала бы что-либо значить (ADR-022, ADR-074).
|
||||
// Ревизию, которой нет, и время, которого нет, бинарь не выдумывает
|
||||
// (ADR-057).
|
||||
func version() {
|
||||
fmt.Println(revision())
|
||||
}
|
||||
|
||||
// revision — ревизия сборки. У бинаря из изменённого рабочего дерева
|
||||
// к ней дописывается «+dirty»: сверка хеша со сборкой из тега — единственное
|
||||
// смягчение против подмены клиента (docs/threat-model.md), и чистый хеш
|
||||
// коммита у бинаря с чужими правками сводил бы её на нет (ADR-057).
|
||||
func revision() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "unknown"
|
||||
info := build.Current()
|
||||
line := info.Version()
|
||||
if !info.CommitAt.IsZero() {
|
||||
line += " " + info.CommitAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
var vcs, modified string
|
||||
for _, s := range info.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
vcs = s.Value
|
||||
case "vcs.modified":
|
||||
modified = s.Value
|
||||
}
|
||||
}
|
||||
if vcs == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if modified == "true" {
|
||||
return vcs + "+dirty"
|
||||
}
|
||||
return vcs
|
||||
fmt.Println(line)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-022: Деплой — nginx, systemd, кросс-сборка
|
||||
|
||||
Уточнён [ADR-032](032-state-permissions.md) (`StateDirectoryMode` и `UMask` в юните), [ADR-056](056-nginx-access-log-off.md) (`access_log off`) и [ADR-057](057-version-marks-dirty-tree.md) (`bare version` помечает сборку из изменённого дерева).
|
||||
Уточнён [ADR-032](032-state-permissions.md) (`StateDirectoryMode` и `UMask` в юните), [ADR-056](056-nginx-access-log-off.md) (`access_log off`), [ADR-057](057-version-marks-dirty-tree.md) (`bare version` помечает сборку из изменённого дерева) и [ADR-074](074-version-and-commit-time.md) (версия и время коммита в `GET /api/config`, времени компиляции в бинаре нет).
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Кому уходит пуш и тексты уведомления — [ADR-045](045-push-addressed-to-recipient.md). Адрес перехода, состояния настроек и жизнь подписки на клиенте — [ADR-046](046-push-client.md).
|
||||
|
||||
Изменён [ADR-068](068-pwa-self-update.md): установленная версия оболочки больше не ждёт закрытия всех вкладок — её включает страница, и она же перезагружается.
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-011 задаёт принцип «пуш — сигнал». Не определено, когда именно слать пуш, как он привязан к устройству и что кэширует service worker.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ADR-024: Айдентика «Скобы», интерфейс и язык
|
||||
|
||||
Уточнён [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) (стороны сообщений зависят от способа ввода).
|
||||
|
||||
## Контекст
|
||||
|
||||
Исследование айдентики (Claude Design, «Исследование айдентики Bare») дало шесть направлений и две мини-айдентики; мок чата построен на варианте 1h «Скобы» и использует только моноширинный шрифт, без «пузырей». Открытые вопросы: язык интерфейса и i18n, визуальная айдентика. Мок содержит элементы, которых в scope v1 нет.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-047: Исходящий запрос к push-сервису
|
||||
|
||||
Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md).
|
||||
Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md). Код причины из ответа push-сервиса в журнале — [ADR-073](073-push-failure-reason-in-log.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Уточняет [ADR-022](022-deploy-nginx-systemd.md): проверка подлинности бинаря опирается на ревизию, значит ревизия обязана быть честной.
|
||||
|
||||
Уточнён [ADR-074](074-version-and-commit-time.md): чтение build info переехало в `internal/build`, ревизия печатается короткой и рядом с временем коммита; `+dirty` и `unknown` — как здесь. Формат стал проверяемым тестом — разбор build info отделён от `debug.ReadBuildInfo`; сама простановка `vcs.*` по-прежнему проверяется руками.
|
||||
|
||||
## Контекст
|
||||
|
||||
`docs/threat-model.md` называет единственное смягчение против активно-злонамеренного оператора: «статика внутри бинаря, хеш которого сверяется со сборкой из тега: подмену можно заметить». `docs/deploy.md` доводит это до двух проверок после деплоя — `sha256sum` на сервере и `bare version`.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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` заменён редактируемым блоком — тем самым «следующим шагом», который записан ниже в «Следствиях».
|
||||
|
||||
## Контекст
|
||||
|
||||
Тест с реальными пользователями (iPhone, Safari и Chrome): над клавиатурой при фокусе в строке ввода всплывает системная панель навигации между полями — «‹ ›» и «готово», около 50 px. Полю переходить некуда — оно на экране одно, — а место съедено.
|
||||
|
||||
Панель — не наша вёрстка, это поведение WebKit для полей, лежащих внутри `<form>`. Строка ввода была оформлена именно так: `<form class="compose">` с `<textarea>` и кнопкой `type="submit"`.
|
||||
|
||||
Дорогой вариант — `contenteditable` вместо `textarea` — снял бы панель гарантированно, но потребовал бы вручную переписать то, что сейчас даёт браузер бесплатно: placeholder, обрезку на 4000 символов, автовысоту, и обязательно — очистку `paste` до текста, потому что `contenteditable` иначе вставляет форматированный HTML, а `innerHTML` в клиенте запрещён (CLAUDE.md).
|
||||
|
||||
Дешёвый вариант — вынести поле из `<form>` — не трогает ничего из этого: `textarea` остаётся `textarea`.
|
||||
|
||||
## Решение
|
||||
|
||||
- `<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 оставались без изменений; актуальное состояние строки ввода задаёт пересмотревший это решение ADR-076.
|
||||
|
||||
Остальные формы (вход, `#/new`, участники, настройки) не тронуты: там несколько полей в форме, панель навигации между ними — по крайней мере, не чистый минус, и незачем менять то, что не жаловались.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Проверить можно только на реальном устройстве — эмуляции панели WebKit нет; headless-Chromium её и не показывал никогда, так что автотест здесь не поставить.
|
||||
- Если панель не уйдёт (вдруг триггер — не `<form>`, а что-то ещё в эвристике Safari), следующий шаг — `contenteditable`, со всей его ценой из «Контекста».
|
||||
- `Enter` на десктопе и кнопка «>» на мобильном по-прежнему отправляют — обработчики `keydown` и `click` делают то же, что раньше `submit`.
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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`, «Чат».
|
||||
|
||||
## Контекст
|
||||
|
||||
Тест с реальными пользователями: единый список с колонкой автора — десктоп сеткой `132px 1fr`, мобильный автором над группой — читается медленно. Чтобы понять, кто автор строки, нужно каждый раз смотреть в колонку слева; с чужого сообщения на своё и обратно глаз идёт через всю ширину.
|
||||
|
||||
Разделение по стороне, как в обычных мессенджерах, снимает это одним взглядом: своё — у своего края, чужое — у чужого. Айдентика «Скобы» этому не мешает и не помогает — про стороны сообщений там ничего нет, — но прямо запрещает пузыри, скругления и тени (`identity/brief.md`, «Цвет»). Значит выравнивание, а не bubble-чат: то же плоское сообщение, что и сейчас, просто у другого края.
|
||||
|
||||
Десктопная сетка `132px 1fr` для этого не годится: колонка автора у неё одна на весь список, слева, общая для всех строк. Разделить строки по сторонам в фиксированной сетке нечем — под это нужен один и тот же поток и на десктопе, и на мобильном, каким мобильный уже пользуется («автор над группой»). Решение — не расширять сетку, а убрать её: десктоп переходит на тот же поток, что мобильный.
|
||||
|
||||
## Решение
|
||||
|
||||
- Лента — везде (десктоп и мобильный) один поток блоков сверху вниз: автор и время строкой, текст под ними, без сетки.
|
||||
- Блок своего сообщения выравнивается по правому краю ленты, чужого — по левому. Свой ник в колонке автора остаётся цветом `mark` (ADR-024) — сторона и цвет вместе, не одно вместо другого.
|
||||
- Ширина блока ограничена (`min(640px, 85%)`), иначе выравнивать нечего: абзац во всю ширину ленты выглядит одинаково с любого края. Текст внутри блока остаётся выровнен по левому краю независимо от стороны — многострочное сообщение с рваным правым краем читается быстрее, чем с рваным левым.
|
||||
- Разделители дат и «новые» — во всю ширину ленты, не по ширине сообщения: они не принадлежат стороне.
|
||||
- Никаких фонов, рамок, скруглений и теней вокруг сообщения — это не бабл, а положение блока.
|
||||
- CSS-класс `feed-list` вместо `grid`: сетки в разметке больше нет, имя не должно об этом врать.
|
||||
|
||||
## Следствия
|
||||
|
||||
- `docs/identity/brief.md`, «Компоновка»: строка про сетку `132px 1fr` заменена на «поток, свои сообщения — у правого края, чужие — у левого, ширина блока ограничена».
|
||||
- `docs/ui.md`, «Чат»: абзац про ленту переписан под общий для обеих ширин экрана поток.
|
||||
- `docs/identity/screens.html` (эталон 2a/2b) обновлён: десктопный мок 2a больше не показывает сетку, оба мока показывают сообщения по сторонам.
|
||||
- Десктопная и мобильная лента визуально сближаются: раньше это были заметно разные раскладки, теперь — одна, с разной шириной блока и отступов. Разница экранов остаётся в сайдбаре, шапке и размере поля ввода (ADR-021, `identity/brief.md`).
|
||||
- Комната с тремя и более участниками: у чужих сообщений сторона всех, кроме себя, одна и та же (левая) — различает их по-прежнему только ник и цвет, сторона тут ничего не добавляет и не мешает.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR-066: серверного «перца» для ключевого блоба нет
|
||||
|
||||
Закрывает последний открытый вопрос из `docs/open-questions.md`, поставленный при [ADR-006](006-e2ee-webcrypto.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
Идея: шифровать ключевой блоб ещё раз серверным ключом, который лежит вне базы — например в `/etc/bare/env` рядом с VAPID. Тогда украденный дамп или бэкап сам по себе не даёт материала для оффлайн-перебора паролей. Против оператора это не помогает: у него есть и база, и файл с ключом.
|
||||
|
||||
Вопрос висел открытым с самого начала и мешает считать модель угроз закрытой: пока решения нет, непонятно, чего стоит ждать от следующей версии хранения.
|
||||
|
||||
## Решение
|
||||
|
||||
Перца нет. Блоб хранится так, как описано в [ADR-006](006-e2ee-webcrypto.md) и [ADR-013](013-password-policy-kdf.md): один слой шифрования, ключ выведен из пароля.
|
||||
|
||||
Аргументы:
|
||||
|
||||
- Рядом с базой появляется второй критичный артефакт. [ADR-003](003-sqlite.md) обещает «бэкап — копия одного файла»; перец это обещание ломает. Потеря файла с перцем — невозможность входа с новых устройств сразу для всех, а восстановления паролей в Bare нет by design. Для маленького самохостного инструмента риск потерять ключ при переезде выше риска, который перец закрывает.
|
||||
- Сценарий «утёк бэкап, но не сервер» уже закрыт [ADR-013](013-password-policy-kdf.md): PBKDF2-HMAC-SHA256 от 600 000 итераций и пароль от 12 символов делают перебор дорогим. Словарный пароль перец тоже не спасёт — при компрометации сервера он утекает вместе с базой.
|
||||
- Каждый дополнительный слой — код, который нужно аудировать, и оперативная процедура, которую нужно помнить. Простота важнее.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Стойкость блоба в любом сценарии равна стойкости пароля, как и записано в `docs/threat-model.md`, «Слабый пароль». Новых оговорок в модели угроз не появляется.
|
||||
- Развёртывание остаётся «бинарь плюс файл базы»: ничего, что нельзя потерять, кроме самой базы, у оператора нет. `/etc/bare/env` восстанавливается по `docs/deploy.md`, VAPID-пара перевыпускается.
|
||||
- Решение обратимо: перец можно добавить позже отдельным ADR — он не меняет формат блоба, только оборачивает его.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR-067: Версия и время коммита в подвале сайдбара
|
||||
|
||||
Доводит до интерфейса [ADR-074](074-version-and-commit-time.md): сервер отдаёт `version` и `commitAt`, а где их показывать, там не решено.
|
||||
|
||||
## Контекст
|
||||
|
||||
`GET /api/config` называет работающую версию и время коммита (ADR-074). Клиент читает конфигурацию при запуске и так — ради `kdfIterations` и `vapidPublicKey`. Показать их негде: `docs/ui.md` такого элемента не знает.
|
||||
|
||||
Спрашивают об этом при каждой поломке: обновилось ли то, что человек видит. В установленном на «Домой» приложении вопрос острее — там нет ни адресной строки, ни консоли, и другого способа узнать версию у человека нет.
|
||||
|
||||
## Решение
|
||||
|
||||
- Версия стоит в подвале сайдбара, рядом с «ты: @nick»: это единственное постоянное место интерфейса, где уже написано, кто и где мы.
|
||||
- Текст — `версия · дд.мм чч:мм`, время коммита в местной зоне. Год не показывается: вопрос «что сейчас работает», а не летопись. Времени нет (`commitAt = 0`) — остаётся одна версия; конфигурации нет вовсе — строки нет.
|
||||
- Цвет `stone`, размер подписи 11 px, без рамок и фона. Акцентом версия не бывает: `mark` — один смысловой элемент на экран (ADR-024), и это непрочитанные, а не служебная строка.
|
||||
- Не кнопка и не ссылка: нажимать в ней нечего. Стоит рядом с кнопкой «ты: @nick», а не внутри неё — иначе экранный диктор зачитывал бы хеш как часть названия кнопки. Выделяется, в отличие от остального сайдбара (ADR-075): на iPhone скопировать её иначе нечем.
|
||||
- В сайдбаре 224 px строка и версия рядом не помещаются никогда: `версия · дд.мм чч:мм` — это 179 px при внутренней ширине 184, а «ты: @nick» просит ещё восемьдесят с лишним. Значит, на десктопе версия всегда занимает вторую строку и стоит справа; на мобильном, где сайдбар во всю ширину, обе стоят в одной строке. Ник, которому не хватило и целой строки, обрезается многоточием; версия — никогда: половина хеша бесполезна.
|
||||
- Текст ради одной строки не режется: вариант «в подвале ревизия, время в `title`» отклонён — `title` не показывается на телефоне, а телефон и есть место, ради которого строка заведена.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Версия видна с любого экрана, где виден сайдбар, и на мобильном — в списке чатов.
|
||||
- Разговор о поломке начинается с версии, а не с «попробуйте обновиться».
|
||||
- Подвал сайдбара стал двухстрочным и вырос с 47 px мока до 76. Это принятое отклонение от `docs/identity/screens.html`, блок 2a: там подвал — одна строка «ты: @nick» без версии и без 44 px цели нажатия. Мок остаётся снимком айдентики на момент ADR-024 и за интерфейсом не идёт: в нём нет и строки `+ новый чат`, и баннера установки.
|
||||
- Названа версия сервера, а не оболочки, которую исполняет браузер: `version` приезжает с `GET /api/config`, а страница и её модули — из кэша service worker. После релиза первое открытие показывает новую версию под старой оболочкой; расходятся они до применения обновления (ADR-068), а с недописанным сообщением в поле — сколь угодно долго. Показывать рядом версию кэша — отдельное решение, здесь его нет.
|
||||
- Записано в `docs/ui.md`, «Список чатов (сайдбар)». Отдельного запроса ради этого нет: поля приезжают с конфигурацией (ADR-074).
|
||||
@@ -0,0 +1,33 @@
|
||||
# ADR-068: Приложение обновляется само
|
||||
|
||||
Меняет политику обновления из [ADR-023](023-push-and-service-worker.md): новая оболочка забирает управление, не дожидаясь закрытия всех вкладок.
|
||||
|
||||
Изменён [ADR-070](070-update-compares-shell-version.md): второй замок — растущая пауза и счётчик `bare-updates` — заменён сверкой версии оболочки.
|
||||
|
||||
Уточнён [ADR-072](072-deferred-update-returns-by-timer.md): к отложенному набранным вкладка возвращается ещё и по таймеру — поле пустеет и молча.
|
||||
|
||||
## Контекст
|
||||
|
||||
`sw.js` обновлялся по умолчанию браузера: новая версия устанавливается, ждёт, и берёт управление, когда закрыты все вкладки приложения. Для сайта это правильно — страница не перезагружается под руками.
|
||||
|
||||
Для установленного на «Домой» приложения это означает «никогда». Его не закрывают: iOS держит его в списке приложений неделями. Строки адреса и кнопки перезагрузки в нём нет — обновить оболочку человеку нечем вовсе. Он остаётся на версии, которая была при установке, и починенное на сервере до него не доезжает.
|
||||
|
||||
Перезагрузка при этом не бесплатна: она уносит набранное в строке ввода и в формах. Пароль в форме входа теряется так же, как черновик сообщения.
|
||||
|
||||
## Решение
|
||||
|
||||
- Клиент спрашивает сервер об обновлении (`registration.update()`) при запуске и при каждом возвращении в приложение (`visibilitychange` → `visible`), но не чаще раза в минуту. Проверка — один условный запрос за `/sw.js`. Без сети запрос падает молча: офлайн не сбой.
|
||||
- Установленная версия не включается сама. Её включает страница: `postMessage("skip-waiting")` воркеру, который ждёт. Воркер отвечает `skipWaiting()`, `activate` забирает клиентов (`clients.claim` там уже есть), браузер шлёт `controllerchange`, страница перезагружается. Просьба уходит только тогда, когда страницей уже кто-то управляет: первая установка проходит через то же состояние «установлен», но включать там нечего — воркер активируется сам, а страница и так свежая.
|
||||
- Набранное откладывает всё: и просьбу включиться, и перезагрузку. Проверяется весь документ, а не строка ввода: клиент — одна страница, и знать про экраны здесь незачем. Набранным считается непустое `textarea` и непустое поле, куда набирают текст (`text`, `password`, `search`, `email`, `url`, `tel`, `number`); чекбокс, файл, скрытое поле и кнопка не в счёт — у них значение непусто по определению, и одно такое поле запретило бы обновление насовсем. Отложенное трогается с места, когда поля опустели (событие `input`) или когда человек вернулся в приложение.
|
||||
- Ждёт каждая вкладка за себя. `skipWaiting()` меняет контроллёра у всех клиентов профиля сразу, а вкладок одного профиля бывает несколько (ADR-035): вкладка с пустыми полями включает новую оболочку, и соседняя с недописанным сообщением остаётся под ней на старой странице — до своей перезагрузки, которой она ждёт по своим полям. Договариваться вкладкам не о чем: терять нечего никому, а пересчёт голосов стоил бы протокола между ними.
|
||||
- Петля закрыта двумя замками. Первый: `controllerchange` перезагружает страницу один раз за её жизнь. Первую установку он отсекает — там контроллёр появляется впервые, `clients.claim` меняет его и без обновления, а страница и так свежая; замок эту смену съедает и запоминает, что страница стала управляемой. Дальше смена контроллёра означает новую оболочку, чью бы просьбу воркер ни исполнял — свою или соседней вкладки. Второй: в `sessionStorage` лежат время последней перезагрузки ради обновления (`bare-updated-at`) и их число в этой вкладке (`bare-updates`), а пауза перед следующей удваивается — 30 секунд, минута, две, четыре и дальше. Второй замок нужен против сервера, отдающего новый `sw.js` на каждый запрос: постоянная пауза задавала бы такой петле только темп, а растущая гасит её — после десятой перезагрузки ждать четыре часа, после двенадцатой сутки, дальше пауза перестаёт расти за ненадобностью. Обычные релизы её не замечают: между ними проходит больше.
|
||||
- Версия кэша в `sw.js` по-прежнему меняется при релизе, и по-прежнему руками (`docs/deploy.md`): именно её смена делает файл воркера другим и запускает всё описанное.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Установленное приложение доезжает до новой версии само — при следующем открытии или в течение минуты после того, как его открыли.
|
||||
- Перезагрузка случается посреди сеанса. Терять ей нечего: набранное её откладывает, история в IndexedDB её переживает, открытый чат задан адресом и открывается снова.
|
||||
- Человек, оставивший недописанное сообщение, остаётся на старой версии, пока не допишет или не сотрёт. Это правильный размен: версия важнее черновика только на словах.
|
||||
- Вкладка с недописанным сообщением какое-то время исполняет старую оболочку под новым воркером: соседняя вкладка включила его, не спросив. Это ничего не стоит — модули страницы уже загружены, догружать их некому (`import()` в клиенте нет), а `/api/` мимо кэша идёт всегда.
|
||||
- У клиента появилось хранилище кроме IndexedDB — два ключа в `sessionStorage`, живущие не дольше вкладки. Записано в `docs/storage.md`.
|
||||
- Отката к «ждать закрытия всех вкладок» не предвидится: на iOS это состояние недостижимо.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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): предел держится до вставки и режет приходящее, а не хвост блока.
|
||||
|
||||
Пересматривает [ADR-064](064-composer-not-form.md): там ту же полосу пробовали убрать выносом поля из `<form>`, считая триггером форму. Не помогло — полоса бывает у `input` и `textarea` всегда. Контейнер `div` с `role="form"` и кнопка `type="button"` из ADR-064 сохраняются: событие `submit` в строке ввода больше не участвует.
|
||||
|
||||
## Контекст
|
||||
|
||||
На iPhone при фокусе в строке сообщения над клавиатурой висит системная полоса помощника форм: две стрелки перехода между полями и «готово». Стрелки неактивны — поле на экране одно, переходить некуда, — а полоса занимает место и ломает ощущение приложения, ради которого принят ADR-075.
|
||||
|
||||
Убрать её со страницы нечем: полосу рисует система, и для `input` и `textarea` она бывает всегда. Единственный работающий приём — перестать быть полем формы: у редактируемого блока (`contenteditable`) iOS полосы не показывает.
|
||||
|
||||
Цена приёма известна заранее. Блок принимает вставку с разметкой, ставит на Enter `<div>` и `<br>`, не знает ни `placeholder`, ни `maxLength`, ни `disabled`, а `innerHTML` в этом клиенте запрещён (ADR-001, CSP): сообщения — пользовательские данные.
|
||||
|
||||
## Решение
|
||||
|
||||
- Блоком становится только строка сообщения в чате. Вход, регистрация, смена пароля и «новый чат» остаются на настоящих `input`: там нужен `type="password"` для менеджеров паролей, а полоса на разовом экране не мешает.
|
||||
- Блок открывается значением `plaintext-only`: в нём браузер кладёт внутрь только текст, чем бы ни был буфер обмена. Значение не назначается, а проверяется чтением — незнакомое значение атрибута делает блок нередактируемым вовсе, то есть строка ввода перестала бы работать молча. Не применилось — остаётся `true`, и тогда вставку, перетаскивание и перенос строки разбирает сам клиент: текст берётся из `text/plain` и вписывается узлом текста.
|
||||
- Значение читается и пишется только `textContent`. `innerHTML` не появляется ни здесь, ни где-либо ещё.
|
||||
- Подсказка — правило CSS `:empty::before` с `content: attr(...)`; текст кладётся атрибутом `data-*`. Опустевший блок очищается от `<br>`, который оставляет в нём браузер: с лишним узлом `:empty` не срабатывает и подсказки не видно.
|
||||
- Клавиши прежние: на десктопе Enter отправляет, Shift+Enter переносит; на мобильном Enter переносит, отправляет кнопка «>». `enterkeyhint` — `enter`: подпись клавиши обещает то, что клавиша делает, а `send` обещал бы отправку, которой по нажатию не будет.
|
||||
- `autocapitalize="sentences"` и `autocorrect="on"`: сообщение — обычная речь, и заглавная в начале предложения с исправлением опечаток тут к месту. В форме входа и в «новом чате» они, наоборот, выключены — там ник и пароль.
|
||||
- Предел 4000 символов и счётчик после 3500 держит сам блок: длина считается по `textContent`, лишнее обрезается у того, что приходит в блок, — так же, как считал `maxLength` у `textarea` (ADR-071).
|
||||
- Заблокированный ввод (предупреждение о ключе, уход из комнаты) перестаёт быть редактируемым и говорит об этом `aria-disabled`: `disabled` у блока нет.
|
||||
- Доступность: `role="textbox"`, `aria-multiline="true"`, подпись `aria-label` тем же текстом, что подсказка; цель нажатия на мобильном не меньше 44 px; фокус при открытии чата на десктопе — как был.
|
||||
- `web/js/zoom.js`: блок добавлен в перечень элементов управления. Гашение второго тапа уносит с собой `click`, а с ним и курсор — второй тап подряд не ставил бы курсор в строку ввода.
|
||||
- `web/js/pwa.js`: проверка «есть ли черновик» (ADR-068) смотрит и в редактируемый блок — `value` у него нет, набранное лежит в `textContent`. Считается и заблокированный блок: недописанное в нём остаётся.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Полоса помощника форм в чате исчезает. На экранах входа и «нового чата» она остаётся — там она и не мешает.
|
||||
- Проверить это можно только на iPhone: Chrome такой полосы не рисует вовсе, и никакой прогон её не увидит.
|
||||
- Строка ввода перестала быть элементом формы. Отправку это не меняет: Enter и раньше обрабатывал клиент, а кнопка «>» отправляет по `click` — `type="button"` и обработчик из [ADR-064](064-composer-not-form.md).
|
||||
- В запасном пути (браузер без `plaintext-only`) отмена набранного (cmd+z) не помнит нашей вставки: она делается руками, а не `execCommand` — тот в Chrome разбирает перенос строки в `<div>`, чего в блоке быть не должно. Там же держится `<br>`-заполнитель последней строки: перенос в самом конце браузер не рисует, и без заполнителя курсор оставался бы на прежней строке.
|
||||
- Автозаполнение и менеджеры паролей блока не касаются — в чате им нечего заполнять.
|
||||
- Записано в `docs/ui.md`, «Чат» и «Доступность».
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-070: Обновление сверяется с версией оболочки, а не со счётчиком перезагрузок
|
||||
|
||||
Заменяет второй замок из [ADR-068](068-pwa-self-update.md): растущая пауза уходит, различение петли и релиза остаётся.
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-068 закрыл петлю перезагрузок двумя замками. Второй — пауза, которая удваивается с каждой перезагрузкой ради обновления: 30 секунд, минута, две, четыре. Считает её счётчик `bare-updates` в `sessionStorage`; он только растёт и обнуляется лишь смертью вкладки.
|
||||
|
||||
Счётчик не различает петлю и обычный релиз. Каждая законная перезагрузка удваивает паузу перед следующей, и вкладка, которая живёт долго, обновляться перестаёт: после десятого релиза ждать четыре часа, после двенадцатого — сутки. Долгоживущая вкладка — это ровно установленное на «Домой» приложение, которое не закрывают неделями, то есть тот самый случай, ради которого ADR-068 и написан. Защита от петли отменяла фичу, которую защищала.
|
||||
|
||||
Различие между петлёй и релизом есть, и оно не в числе перезагрузок: **при петле версия оболочки после перезагрузки не меняется, при релизе меняется**. Сервер, отдающий новый файл воркера на каждый запрос, отдаёт ту же оболочку — версия в `sw.js` пишется руками при релизе (`docs/deploy.md`), и сама собой она не меняется.
|
||||
|
||||
## Решение
|
||||
|
||||
- `sw.js` отвечает на сообщение `version` своей константой `VERSION`. Ответ уходит каналом вопроса (`MessageChannel`): вопросов бывает два подряд и к разным воркерам.
|
||||
- Страница спрашивает версию у своего контроллёра при запуске. Это версия оболочки, код которой она исполняет: модули пришли из кэша именно этого воркера.
|
||||
- Смена контроллёра ведёт к перезагрузке, только если версия включившейся оболочки другая. Та же версия означает, что сервер отдал другой файл воркера при неизменной оболочке: перезагрузка вернула бы ту же самую страницу, и следующий запрос за `sw.js` начал бы всё сначала. Петля обрывается здесь и целиком — перезагрузок в ней не случается вовсе.
|
||||
- Версия, которой не назвали (воркер прежнего выпуска такого вопроса не знает, ответа нет за три секунды), считается другой: обновление до выпуска, который отвечает, важнее. Повторяться этому не с чего — после перезагрузки отвечают оба.
|
||||
- Первая установка запоминает версию нового контроллёра: страница пришла из сети, и оболочка, которую она исполняет, — та, что только что встала.
|
||||
- Пауза между перезагрузками остаётся, но постоянная — 30 секунд, и `bare-updates` уходит. Замком от петли она больше не служит; это предел частоты на случай сервера, который отдаёт разные версии на каждый запрос. Отметка времени `bare-updated-at` остаётся в `sessionStorage`.
|
||||
- Отложенная паузой перезагрузка возвращается сама, по таймеру: другого повода может и не быть — поля пусты, вкладка открыта, а вкладка спрашивает сервер об обновлении только при запуске и при возвращении в приложение (ADR-068).
|
||||
|
||||
## Следствия
|
||||
|
||||
- Десять релизов подряд за жизнь одной вкладки доезжают все и через одну и ту же паузу: замер — 29–30 секунд на релиз. Прежний замок на тех же релизах давал 30, 60, 120, 240 секунд и дальше вдвое.
|
||||
- Сервер, отдающий новый `sw.js` на каждый запрос при неизменной версии, перезагрузок не вызывает ни одной. Воркер он при этом меняет — это дело браузера, и стоит оно одной установки в минуту, не чаще: чаще страница сервер не спрашивает.
|
||||
- Релиз без смены `VERSION` страницу не перезагружает. Это не новое ограничение: ADR-023 обещал такому релизу только обновление статики по ETag.
|
||||
- В `sw.js` появился второй вопрос от страницы. Протокола (`docs/protocol.md`) это не касается: разговор идёт внутри браузера.
|
||||
- `docs/storage.md`: у клиента остаётся один ключ в `sessionStorage` вместо двух.
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-071: Предел строки сообщения держится до вставки
|
||||
|
||||
Пересмотрен [ADR-076](076-ios-input-accessory-is-system.md): после возврата к `textarea` предел снова держит нативный `maxLength`; ручная обработка вставки больше не нужна.
|
||||
|
||||
Уточняет [ADR-069](069-message-input-is-editable-block.md): предел 4000 символов держит по-прежнему сам блок, но режет он приходящее, а не набранное.
|
||||
|
||||
## Контекст
|
||||
|
||||
Предел проверялся после вставки: блок переписывался значением, обрезанным по пределу. Резался при этом хвост всего содержимого, а не то, что только что пришло. Замер: в блоке 3950 «A», курсор на позиции 10, вставка 200 символов из буфера — в блоке 4000 символов, из них 3800 «A»: полтораста набранных символов исчезли. Отмена их не вернула: присваивание `textContent` сносит стек отмены браузера. `maxLength` у прежней `textarea` вёл себя обратно — резал вставляемое и не трогал ни одного уже набранного символа.
|
||||
|
||||
Тем же переписыванием блока стояли рядом ещё три расхождения с прежним полем:
|
||||
|
||||
- обрезка шла по единицам UTF-16 и разрывала суррогатную пару. 3999 «q» и набранное «😀» оставляли в блоке одинокий старший суррогат, и он доезжал собеседнику. `maxLength` эмодзи на границе просто не принимал.
|
||||
- проверка срабатывала во время композиции IME. У предела композиция обрывалась на полуслове, а подтверждённый ввод не попадал в блок вовсе.
|
||||
- курсор уезжал в конец на каждом нажатии. Править середину полного сообщения было нельзя; `maxLength` лишний символ не пускал и курсора не трогал.
|
||||
|
||||
И два счёта мимо: заполнитель последней строки, который Chrome держит в `plaintext-only` вторым `\n` в самом конце, попадал и в счётчик, и в предел — перенос в конце стоил двух символов из четырёх тысяч. А в запасном пути (браузер без `plaintext-only`) перетаскивание текста внутри самого блока раздваивало его: вставку там делаем мы, отменяя действие браузера, и уносить исходное браузеру после этого нечем.
|
||||
|
||||
## Решение
|
||||
|
||||
- Предел держится до вставки, а не после. `beforeinput` знает и что придёт (`data`, для буфера и мыши — `dataTransfer`), и что заменится (`getTargetRanges`). Влезающее вставляет браузер сам — тогда и отмена остаётся его. Не влезающее отменяется и вписывается обрезанным по свободному месту.
|
||||
- Свободное место — предел минус то, что останется от набранного, когда заменяемое уйдёт. Набранное не режется никогда: сверх предела не проходит только приходящее.
|
||||
- Резать по границе суррогатной пары нельзя: не влезла старшая половина — не вставляется и она. Эмодзи, которому не хватило одного символа, не попадает в блок вовсе — так же, как не пускал его `maxLength`.
|
||||
- Композиция IME не трогается ничем: ни проверкой до вставки, ни обрезкой после. Лишнее снимается один раз, на `compositionend`; пока идёт композиция, счётчик стоит на месте.
|
||||
- Обрезка после вставки остаётся последней страховкой — для того, что приехало мимо `beforeinput`. Она снимает ровно хвост сверх предела правкой текстовых узлов, а не переписывает блок: курсор остаётся там, где стоял, стек отмены цел.
|
||||
- Считается и режется то, что уедет собеседнику: заполнитель последней строки в счёт не идёт. В `plaintext-only` это лишний `\n` в самом конце — в `textContent` он виден, а в сообщении его нет, его снимает `trim` при отправке. В запасном пути заполнитель — `<br>`, которого в `textContent` нет вовсе, и перенос в конце там настоящий.
|
||||
- Перетаскивание изнутри блока в запасном пути объявляется копированием (`effectAllowed = "copy"`). Переноса при отменённом действии по умолчанию не выйдет, и молчаливое удвоение становится честной копией.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Вставка в середину длинного сообщения больше не стирает набранное, и отмена работает везде, где вставку делает браузер.
|
||||
- Отмена по-прежнему не помнит нашу вставку там, где её делаем мы: при переполнении и в запасном пути (ADR-069). Терять там уже нечего — часть вставленного и так не влезла.
|
||||
- Одинокого суррогата в сообщении не бывает.
|
||||
- Счётчик у порога говорит правду: перенос в конце стоит одного символа, а не двух.
|
||||
- Курсор на пределе остаётся на месте: полное сообщение можно править с середины.
|
||||
- Перетаскивание изнутри блока прогоном не проверяется: самодельный `DataTransfer` присвоения не принимает, а настоящее перетаскивание браузер без окна из страницы не начинает. Проверка — руками, в браузере без `plaintext-only`.
|
||||
- Записано в `docs/ui.md`, «Чат».
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-072: Отложенное обновление возвращается по таймеру
|
||||
|
||||
Уточняет [ADR-068](068-pwa-self-update.md): поводов вернуться к отложенному было два, и оба мимо самого частого случая.
|
||||
|
||||
## Контекст
|
||||
|
||||
Обновление ждёт, пока в полях вкладки набранное: перезагрузка унесла бы его. Вернуться к отложенному могли только событие `input` и возвращение в приложение.
|
||||
|
||||
Строка ввода пустеет и без того, и без другого. Отправка сообщения чистит блок присваиванием — события `input` это не порождает. Экран, с которого ушли, выбывает из документа вместе со своим полем — тем более.
|
||||
|
||||
Замер: во вкладке с черновиком соседняя вкладка включила новый выпуск, контроллёр сменился, перезагрузка отложена. Enter отправляет сообщение, поле пусто, вкладка на виду — за полторы минуты перезагрузки нет, оболочка прежняя. Один цикл смены видимости — перезагрузка через две секунды. То есть после самого обычного действия, «написал и отправил», приложение остаётся на старой оболочке до следующего возвращения в него, хотя платить за обновление уже нечем. Ради этого случая ADR-068 и написан.
|
||||
|
||||
## Решение
|
||||
|
||||
- Отложенное набранным возвращается само, по таймеру: полминуты, тем же таймером, которым возвращается отложенное паузой между перезагрузками (ADR-070). Поля пусты — обновление идёт дальше; набранное на месте — таймер заводится снова.
|
||||
- Таймер заводится только тогда, когда обновление уже ждёт: оболочка сменилась или установленная версия ждёт включения. Иначе проверка полей крутилась бы вхолостую всю жизнь вкладки.
|
||||
- Событие `input` остаётся: опустевшее поле оно замечает сразу, а таймер — не позже чем через полминуты.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Отправленное сообщение больше не держит вкладку на старой оболочке: обновление доезжает в течение полуминуты после того, как поле опустело.
|
||||
- Уход с экрана — то же самое: поле пропадает вместе с экраном, и заметить это иначе нечем.
|
||||
- Пока обновление ждёт, а в полях набранное, вкладка раз в полминуты обходит свои поля. Это дешевле, чем протокол между экранами и обновлением.
|
||||
- Записано в `docs/ui.md`, «Сеть и состояния».
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-073: Код причины отказа push-сервиса в журнале
|
||||
|
||||
Уточняет [ADR-047](047-push-endpoint.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-047 убрал из журнала адрес подписки: отказ отправки пишется классом, текст ошибки транспорта не печатается вовсе. Под то же правило попал и ответ push-сервиса — в журнал уходил один статус: `пуш: push-сервис ответил 403`.
|
||||
|
||||
Цена выяснилась на работающем сервере. APNs отвечал `403` на каждый пуш, потому что в VAPID-токен уезжал `sub = "mailto:mailto:admin@xmatic.team"`: webpush-go приписывает `mailto:` всему, что не начинается с `https:`, а в окружении субъект записан правильным URI. Сутки не уходило ни одного пуша, и по журналу это выглядело как «что-то с пушами»: просроченный ключ, чужой субъект, лимит вендора и мёртвая подписка дают один и тот же `403`. Причина лежала в теле ответа: `{"reason":"BadJwtToken"}`.
|
||||
|
||||
Код причины от push-сервиса — диагностика вендора, а не данные пользователя. Но тело ответа приходит снаружи, и класть его в журнал целиком нельзя: в нём может оказаться адрес подписки.
|
||||
|
||||
## Решение
|
||||
|
||||
- В журнал уходит статус и, если он разобран, короткий код причины: `пуш: push-сервис ответил 403 (BadJwtToken)`.
|
||||
- Читается не больше 200 байт тела; остаток дочитывается ради переиспользования соединения. Код берётся из полей JSON `reason`, `error`, `message` — в этом порядке — либо из тела целиком, если оно само короткая строка.
|
||||
- В журнал идёт только первая строка не длиннее 64 символов из латиницы, цифр, `_`, `-` и пробелов. Точка, `:`, `/` и `@` встречаются в адресах подписки и именах хостов, поэтому строка с ними отбрасывается целиком и остаётся один статус.
|
||||
- Правило ADR-047 про транспорт не меняется: ошибка соединения по-прежнему сводится к классу, её текст не печатается, адреса подписки в журнале нет.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Отказ push-сервиса читается по журналу и чинится по нему же.
|
||||
- Часть вендорских кодов не разберётся: FCM отвечает вложенным объектом, а коды с точкой в имени не проходят алфавит. Тогда в журнале остаётся статус, как раньше. Осознанно: молчание безопаснее догадок о чужом теле.
|
||||
- Ответы `404` и `410` пишутся не строкой, а снятием подписки — так было и остаётся (ADR-011).
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-074: Версия — короткая ревизия, «время сборки» — время коммита
|
||||
|
||||
Уточняет [ADR-022](022-deploy-nginx-systemd.md) (подлинность бинаря проверяется сверкой хеша со сборкой из тега) и [ADR-057](057-version-marks-dirty-tree.md) (`+dirty` у сборки из изменённого дерева).
|
||||
|
||||
Доведён до интерфейса [ADR-067](067-version-in-sidebar-foot.md): версия и время коммита стоят в подвале сайдбара.
|
||||
|
||||
## Контекст
|
||||
|
||||
Интерфейс должен называть версию, которая сейчас работает: иначе разговор о поломке начинается с угадывания, обновилось ли то, что человек видит.
|
||||
|
||||
Ревизия у сервера уже есть — `revision()` в `cmd/bare` читает `vcs.revision` и `vcs.modified` (ADR-057), — но живёт в `main` и снаружи недоступна. `internal/api` до неё не дотягивается.
|
||||
|
||||
Второй вопрос — «время сборки». Естественный ответ, штамп момента компиляции через `-ldflags -X`, ломает ADR-022: артефакт деплоя — один файл, и его подлинность проверяется тем, что сборка из тега даёт тот же хеш. Со временем компиляции внутри каждая пересборка одного и того же коммита даёт другой файл, и сверка перестаёт что-либо значить. Рядом с ревизией лежит `vcs.time` — время коммита, приходящее из git вместе с ней. Оно воспроизводимо и отвечает на настоящий вопрос: какой код работает, а не когда его компилировали.
|
||||
|
||||
## Решение
|
||||
|
||||
- Чтение build info переезжает в `internal/build`: полная ревизия, признак изменённого дерева, время коммита. `cmd/bare` и `internal/api` берут версию оттуда — двух форматов у одной величины не заводится.
|
||||
- Версия — семь символов ревизии. У сборки из изменённого дерева к ним дописывается `+dirty` (ADR-057), без ревизии вовсе — `unknown`.
|
||||
- «Время сборки» — это `vcs.time`, время коммита. Штампа момента компиляции в бинаре нет и не появится.
|
||||
- `GET /api/config` отдаёт `version` (строка) и `commitAt` (миллисекунды Unix, `0` если времени нет). Отдельного `/api/version` нет: конфигурацию клиент читает до входа и так, а второй эндпоинт ради двух полей — лишний маршрут. В других ответах версии тоже нет.
|
||||
- `bare version` печатает то же самое: короткую ревизию и время коммита в UTC одной строкой.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Сверка «хеш файла на сервере против сборки из тега» остаётся проверяемой: в бинаре не осталось ничего, что менялось бы от пересборки.
|
||||
- `bare version` больше не печатает полный хеш. Семи символов хватает и человеку, и `git show`; `docs/deploy.md` говорит то же.
|
||||
- Время в интерфейсе отстаёт от момента деплоя ровно настолько, насколько деплой отстал от коммита. Это и спрашивают.
|
||||
- Сборка не из git — `go run`, `go build -buildvcs=false`, тестовый бинарь — показывает `unknown` и `0`. Тесты видят именно её: `vcs.*` тестовому бинарю не проставляются, и это состояние проверяется ими прямо.
|
||||
- Клиенту остаётся показать два поля; где именно и какими словами — `docs/ui.md`.
|
||||
@@ -0,0 +1,32 @@
|
||||
# ADR-075: Масштабирования нет, размеры шрифтов прежние
|
||||
|
||||
Уточняет [ADR-024](024-identity-and-ui.md): типографика из `docs/identity/brief.md` остаётся как записана, а страница перестаёт масштабироваться.
|
||||
|
||||
Уточнён [ADR-076](076-ios-input-accessory-is-system.md): строка сообщения снова стала `textarea`, входит в перечень элементов управления, по которым второй тап не гасится, и заменяет весь нижний отступ на 6 px только при открытой экранной клавиатуре.
|
||||
|
||||
## Контекст
|
||||
|
||||
На iPhone интерфейс жил в постоянных микрозумах. Тап по строке ввода приближал страницу: iOS Safari обязан приблизить поле мельче 16 px и обратно масштаб не возвращает. Двойной тап приближал её же, щипок — тем более. Ощущения приложения не было: страница ездила под пальцами.
|
||||
|
||||
Лечится это двумя способами, и они друг друга исключают. Первый — вырастить поля до 16 px: причина фокус-зума уходит, масштаб остаётся человеку. Второй — запретить масштабирование целиком. Владелец выбрал второй и отклонил первый прямо: «интерфейс должен быть как в нативном приложении, это нужно запретить», «с 16 шрифтом всё станет огромное».
|
||||
|
||||
Образец рядом — sixlines.ru той же команды: там `maximum-scale=1, user-scalable=no`, и на устройствах владельца страница не масштабируется.
|
||||
|
||||
## Решение
|
||||
|
||||
- Масштабирование запрещено, включая щипок. `viewport` — `width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`.
|
||||
- Размеры шрифтов не меняются: поля ввода остаются 14 px, остальное — как в `docs/identity/brief.md`. 16 px на полях владельцем отклонены.
|
||||
- Установленное приложение объявляется двумя мета-строками сразу: `mobile-web-app-capable` и его исторический двойник `apple-mobile-web-app-capable` — iOS до сих пор смотрит на второй. Имя на экране «Домой» — `apple-mobile-web-app-title` со значением `bare`.
|
||||
- `apple-mobile-web-app-status-bar-style` — `default`. Тема одна и светлая (ADR-024): `black-translucent` пустил бы страницу под системную полосу и написал бы её часы и значки белым по bone, то есть по светлому. `default` оставляет полосу над страницей, тёмными знаками по светлому фону.
|
||||
- `touch-action: manipulation` на `html`: снимает зум по двойному тапу и задержку 300 мс, которую браузер держит, ожидая второго касания. Двойной тап — жест документа, поэтому правило на корне действует на всю страницу.
|
||||
- Страховка на 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)`, страница — боковые и верхнюю вставки. Когда открыта экранная клавиатура, обычные `20px + env(safe-area-inset-bottom)` строки ввода заменяются на 6 px по ADR-076: клавиатура уже закрывает домашний индикатор. Верхняя вставка при `status-bar-style: default` нулевая и не делает ничего; она стоит на случай, когда браузер решит иначе, — строка кода против шапки, уехавшей под часы.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Человек, которому нужно увеличить мелкое, средства лишается. Это сознательная цена ощущения приложения, и платят её все. Записана в `docs/ui.md`, «Доступность», рядом с контрастом и целями нажатия — чтобы её видели, а не находили.
|
||||
- Safari вправе не послушаться: `user-scalable=no` он игнорирует с десятой версии, и `maximum-scale` однажды может пойти тем же путём. Тогда вернётся и фокус-зум на поле в 14 px — лечить его 16-м шрифтом отклонено, а другого лекарства нет.
|
||||
- Двойной тап по кнопке не гасится страховкой: масштаба он и так не даёт, а второе нажатие важнее.
|
||||
- Клиент вырос на один модуль — `web/js/zoom.js`. Он в оболочке service worker и в раскладке `docs/plan.md`.
|
||||
@@ -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 относится только к показанной форме.
|
||||
|
||||
Вход по имени, публичный каталог и ссылки-приглашения не добавляются. Такой сценарий потребует отдельного адреса или уникальности, правил одобрения и раздачи ключа; это не изменение текста формы.
|
||||
+6
-3
@@ -8,7 +8,7 @@
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bare ./cmd/bare
|
||||
```
|
||||
|
||||
Версия бинаря — `vcs.revision` из `debug.ReadBuildInfo()`, печатается по `bare version`; у сборки из изменённого рабочего дерева (`vcs.modified`) к ревизии дописывается `+dirty` — сверка со сборкой из тега не должна проходить молча (ADR-057). `/healthz` отвечает только `ok`.
|
||||
Версия бинаря — `vcs.revision` и `vcs.time` из `debug.ReadBuildInfo()`. `bare version` печатает семь символов ревизии и время коммита в UTC: `cfd0ec0 2026-08-23T04:54:16Z`. У сборки из изменённого рабочего дерева (`vcs.modified`) к ревизии дописывается `+dirty` — сверка со сборкой из тега не должна проходить молча (ADR-057); у сборки не из git печатается `unknown`. Времени компиляции в бинаре нет: оно делало бы каждую пересборку одного коммита новым файлом (ADR-074). Те же значения отдаёт `GET /api/config` полями `version` и `commitAt`. `/healthz` отвечает только `ok`.
|
||||
|
||||
## Первичная настройка сервера (один раз)
|
||||
|
||||
@@ -35,6 +35,8 @@ BARE_INVITE_CODE=<пусто или код>
|
||||
|
||||
`bare vapid` печатает пару ключей; выполняется локально один раз, результат вписывается в файл.
|
||||
|
||||
`BARE_VAPID_SUBJECT` — URI по RFC 8292: `mailto:<адрес>` или `https://<хост>`. Форма проверяется при старте: с пустым при заданных ключах или с голым адресом без схемы сервер не поднимается. Отдавать субъект библиотеке приходится без схемы `mailto:` — она приписывает её сама, и готовый URI превратился бы в `mailto:mailto:…`, который push-сервис отвергает; нормализация живёт в `internal/push`, запись в этом файле верна и не меняется.
|
||||
|
||||
`/etc/systemd/system/bare.service`:
|
||||
|
||||
```ini
|
||||
@@ -119,7 +121,7 @@ sudo systemctl daemon-reload && sudo systemctl enable --now bare
|
||||
|
||||
## Обновление — `scripts/deploy.sh`
|
||||
|
||||
Перед сборкой: если менялись `index.html`, `app.css`, `js/*`, `manifest.json` или иконки — сменить `VERSION` в `web/sw.js` (ADR-023). Без этого установленные приложения получат новую оболочку только вторым открытием, по ETag.
|
||||
Перед сборкой: если менялись `index.html`, `app.css`, `js/*`, `manifest.json` или иконки — сменить `VERSION` в `web/sw.js` (ADR-023). Смена версии делает файл воркера другим, и установленные приложения обновляются сами: клиент замечает новую оболочку при запуске или при возвращении в приложение, включает её и перезагружает страницу (ADR-068). Без смены версии новая статика доедет только по ETag, вторым открытием, — и во вкладке, и в установленном приложении одинаково: обработчик `fetch` у них один. Но воркер и предзагруженный список оболочки останутся прежними, а самообновление не запустится вовсе.
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
@@ -136,6 +138,7 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud
|
||||
- `curl -I https://bare.xmatic.team/` — 200, заголовки CSP и nosniff.
|
||||
- `curl -N https://bare.xmatic.team/api/events` — 401 (без cookie), без буферизации.
|
||||
- `curl -s https://bare.xmatic.team/sw.js | grep VERSION` — версия та, что в репозитории.
|
||||
- `curl -s https://bare.xmatic.team/api/config` — `version` равен `git rev-parse --short=7 HEAD` задеплоенного коммита и без `+dirty`. Длина задана явно: сервер режет ревизию ровно до семи символов, а `--short` без числа берёт её из `core.abbrev` и растит по мере роста репозитория.
|
||||
- `journalctl -u bare -f` — старт, применённые миграции, нет ошибок.
|
||||
|
||||
## Бэкап
|
||||
@@ -144,6 +147,6 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud
|
||||
|
||||
## Логи
|
||||
|
||||
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам.
|
||||
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). Ответ push-сервиса пишется статусом и коротким кодом причины из тела: «пуш: push-сервис ответил 403 (BadJwtToken)». Код — диагностика вендора; всё, что на короткий код не похоже, отбрасывается целиком, и остаётся один статус (ADR-073). journald хранит по своим правилам.
|
||||
|
||||
nginx журнал запросов не ведёт: `access_log off` в обоих server-блоках (ADR-056). Без этой строки он унаследовал бы `access.log` формата `combined` из `/etc/nginx/nginx.conf` — с адресом клиента и полным URI, то есть с ником и социальным графом. `error_log` остаётся: это журнал сбоев, а не запросов, и при отказе он записывает адрес клиента.
|
||||
|
||||
@@ -37,10 +37,13 @@ font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monos
|
||||
|
||||
Размеры: текст сообщений 14 px / 1.55; автор и время 12 px; заголовки секций 10 px, разрядка 0.14em, uppercase; подписи 11 px; имя чата в шапке 15 px. Шрифты не загружаются.
|
||||
|
||||
Поля ввода — те же 14 px, исключений нет: масштабирование в клиенте запрещено, и растить шрифт ради обхода фокус-зума iOS не приходится (ADR-075).
|
||||
|
||||
## Компоновка
|
||||
|
||||
- Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px; сообщения — сетка `132px 1fr`, column-gap 20, row-gap 6.
|
||||
- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px.
|
||||
- Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px.
|
||||
- Мобильный: шапка 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 |
+16
-26
@@ -23,13 +23,17 @@
|
||||
.item { font-size:13px; padding:6px 8px; color:var(--text2); display:flex; }
|
||||
.item.active { background:var(--ink); color:var(--bone); }
|
||||
.item .n { margin-left:auto; color:var(--mark); }
|
||||
.divider { display:flex; align-items:center; gap:14px; font-size:11px; color:var(--stone); }
|
||||
.divider { display:flex; align-items:center; align-self:stretch; gap:14px; font-size:11px; color:var(--stone); }
|
||||
.divider::before, .divider::after { content:""; flex:1; height:1px; background:var(--line); }
|
||||
.divider.new { color:var(--mark); }
|
||||
.divider.new::before, .divider.new::after { background:var(--mark); }
|
||||
.author { color:var(--mute); font-size:12px; padding-top:2px; }
|
||||
.author { color:var(--mute); font-size:12px; }
|
||||
.author.me { color:var(--mark); }
|
||||
.author .t { color:var(--stone); }
|
||||
/* поток сообщений: на 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; }
|
||||
.input { display:flex; align-items:center; gap:12px; border:1px solid var(--ink); padding:14px 16px; font-size:14px; }
|
||||
.input .p { color:var(--mark); }
|
||||
.input .caret { width:8px; height:17px; background:var(--ink); }
|
||||
@@ -38,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; }
|
||||
@@ -46,21 +51,17 @@
|
||||
.me i { width:6px; height:6px; background:var(--ink); }
|
||||
.main { display:flex; flex-direction:column; min-width:0; }
|
||||
.head { height:64px; display:flex; align-items:center; gap:14px; padding:0 32px; border-bottom:1px solid var(--line); font-size:15px; }
|
||||
.feed { flex:1; padding:28px 32px; display:flex; flex-direction:column; justify-content:flex-end; overflow:hidden; }
|
||||
.feed { flex:1; padding:28px 32px; display:flex; flex-direction:column; align-items:flex-start; justify-content:flex-end; gap:14px; overflow:hidden; font-size:14px; line-height:1.55; }
|
||||
.feed .divider { padding:10px 0 22px; }
|
||||
.grid { display:grid; grid-template-columns:132px 1fr; column-gap:20px; row-gap:6px; font-size:14px; line-height:1.55; }
|
||||
.grid .divider { grid-column:1 / -1; padding:16px 0; }
|
||||
.compose { padding:0 32px 28px; }
|
||||
|
||||
/* 2b — мобильный */
|
||||
.mobile { width:390px; height:700px; display:flex; flex-direction:column; }
|
||||
.mobile .head { height:56px; padding:0 20px; gap:12px; font-size:14px; }
|
||||
.mobile .mark { width:16px; height:16px; stroke-width:8; }
|
||||
.mobile .feed { padding:20px; gap:14px; }
|
||||
.mobile .feed { padding:20px; font-size:14px; line-height:1.5; }
|
||||
.mobile .feed .divider { padding:0; font-size:10px; }
|
||||
.msg { display:flex; flex-direction:column; gap:4px; }
|
||||
.msg .author { font-size:11px; }
|
||||
.msg p { margin:0; font-size:14px; line-height:1.5; }
|
||||
.mobile .compose { padding:0 16px 20px; }
|
||||
.mobile .input { padding:13px 14px; min-height:44px; gap:10px; }
|
||||
.mobile .input .caret { width:7px; height:16px; }
|
||||
@@ -92,23 +93,12 @@
|
||||
<div class="head">#general</div>
|
||||
<div class="feed">
|
||||
<div class="divider">вторник, 18 августа</div>
|
||||
<div class="grid">
|
||||
<div class="author">marta <span class="t">11:52</span></div>
|
||||
<div>выкатила статику на bare.xmatic.team, кэш чистится сам</div>
|
||||
<div></div>
|
||||
<div>вес страницы — 14 кб. без шрифтов было бы 9, но mono того стоит</div>
|
||||
<div class="author">lev <span class="t">11:58</span></div>
|
||||
<div>смотрю network: один html, один css, ноль js до первого сообщения. красиво</div>
|
||||
<div class="author me">kir <span class="t">12:03</span></div>
|
||||
<div>это и есть план. если фича требует бандлер — фича не нужна</div>
|
||||
<div></div>
|
||||
<div>доки пишу прямо в readme, отдельного сайта не будет</div>
|
||||
<div class="divider new">новые</div>
|
||||
<div class="author">marta <span class="t">12:41</span></div>
|
||||
<div>кто-то с hn спрашивает, где мобильное приложение</div>
|
||||
<div class="author">lev <span class="t">12:42</span></div>
|
||||
<div>ответил: браузер и есть приложение</div>
|
||||
</div>
|
||||
<div class="msg"><div class="author">marta <span class="t">11:52</span></div><p>выкатила статику на bare.xmatic.team, кэш чистится сам</p><p>вес страницы — 14 кб. без шрифтов было бы 9, но mono того стоит</p></div>
|
||||
<div class="msg"><div class="author">lev <span class="t">11:58</span></div><p>смотрю network: один html, один css, ноль js до первого сообщения. красиво</p></div>
|
||||
<div class="msg me"><div class="author me">kir <span class="t">12:03</span></div><p>это и есть план. если фича требует бандлер — фича не нужна</p><p>доки пишу прямо в readme, отдельного сайта не будет</p></div>
|
||||
<div class="divider new">новые</div>
|
||||
<div class="msg"><div class="author">marta <span class="t">12:41</span></div><p>кто-то с hn спрашивает, где мобильное приложение</p></div>
|
||||
<div class="msg"><div class="author">lev <span class="t">12:42</span></div><p>ответил: браузер и есть приложение</p></div>
|
||||
</div>
|
||||
<div class="compose">
|
||||
<div class="input"><span class="p">></span><span>сообщение в #general</span><span class="caret"></span><span class="hint">enter — отправить</span></div>
|
||||
@@ -125,7 +115,7 @@
|
||||
<div class="divider">18 авг</div>
|
||||
<div class="msg"><div class="author">marta <span class="t">11:52</span></div><p>выкатила статику на bare.xmatic.team, кэш чистится сам</p><p>вес страницы — 14 кб</p></div>
|
||||
<div class="msg"><div class="author">lev <span class="t">11:58</span></div><p>один html, один css, ноль js до первого сообщения. красиво</p></div>
|
||||
<div class="msg"><div class="author me">kir <span class="t">12:03</span></div><p>это и есть план. если фича требует бандлер — фича не нужна</p></div>
|
||||
<div class="msg me"><div class="author me">kir <span class="t">12:03</span></div><p>это и есть план. если фича требует бандлер — фича не нужна</p></div>
|
||||
<div class="divider new">новые</div>
|
||||
<div class="msg"><div class="author">marta <span class="t">12:41</span></div><p>кто-то с hn спрашивает, где мобильное приложение</p></div>
|
||||
<div class="msg"><div class="author">lev <span class="t">12:42</span></div><p>ответил: браузер и есть приложение</p></div>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
Решения по этим пунктам ещё не приняты. Каждое принятое решение уходит в ADR и вычёркивается отсюда.
|
||||
|
||||
- Серверный «перец» для ключевого блоба: дополнительное шифрование блоба серверным ключом, хранящимся вне базы. Плюс: дамп базы сам по себе перестаёт быть материалом для оффлайн-перебора. Минус: не защищает от оператора; потеря серверного ключа — невозможность входа с новых устройств для всех. Решение отложено.
|
||||
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
|
||||
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
|
||||
|
||||
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей.
|
||||
Закрыто 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 — выбор между личным чатом и новой комнатой.
|
||||
|
||||
+3
-1
@@ -18,6 +18,7 @@
|
||||
```
|
||||
embed.go //go:embed web в корне модуля (ADR-025)
|
||||
cmd/bare/main.go подкоманды: serve, vapid, version
|
||||
internal/build/ ревизия и время коммита из build info (ADR-074)
|
||||
internal/config/ переменные BARE_*
|
||||
internal/store/ SQLite, migrations/*.sql (embed), запросы
|
||||
internal/auth/ argon2id, сессии, cookie
|
||||
@@ -33,8 +34,9 @@ web/
|
||||
js/crypto.js всё из crypto.md
|
||||
js/db.js IndexedDB из storage.md
|
||||
js/sync.js устройство, поток событий, приём и отправка
|
||||
js/pwa.js service worker, подписка на пуши, установка
|
||||
js/pwa.js service worker, самообновление (ADR-068), пуши, установка
|
||||
js/ulid.js ULID
|
||||
js/zoom.js запрет масштабирования (ADR-075)
|
||||
js/ui/*.js экраны из ui.md
|
||||
js/export.js .bare
|
||||
scripts/deploy.sh
|
||||
|
||||
+3
-1
@@ -42,7 +42,9 @@ WrappedKey { to: nick, iv: string, ct: string }
|
||||
|
||||
## Публичные
|
||||
|
||||
`GET /api/config` → `200 {inviteRequired: bool, vapidPublicKey: string, kdfIterations: number, maxMessageChars: 4000}`
|
||||
`GET /api/config` → `200 {inviteRequired: bool, vapidPublicKey: string, kdfIterations: number, maxMessageChars: 4000, version: string, commitAt: number}`
|
||||
|
||||
`version` — короткая ревизия сборки: семь символов хеша коммита, с суффиксом `+dirty` у бинаря из изменённого дерева (ADR-057) и `unknown` у сборки не из git. `commitAt` — время коммита в миллисекундах Unix, `0` если оно неизвестно. Это время коммита, а не момент компиляции: штамп времени сборки лишил бы смысла сверку хеша бинаря со сборкой из тега (ADR-022, ADR-074). Те же значения печатает `bare version`. Отдельного эндпоинта у них нет.
|
||||
|
||||
`GET /api/kdf?nick=<nick>` → `200 {iterations}`. Для неизвестного ника — `kdfIterations` из конфигурации, тем же статусом. Скрытием существования ника ответ не занимается: у аккаунта, не входившего после повышения цели, число итераций своё (ADR-062), а сам факт, что ник существует, публичен (ADR-019).
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ peers key: nick
|
||||
- Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`.
|
||||
- Пагинация — курсор по индексу `chat` назад от последнего, по 50.
|
||||
- При старте: `navigator.storage.persist()`; в настройках — `storage.estimate()`.
|
||||
- Кроме IndexedDB клиент держит один ключ в `sessionStorage` — `bare-updated-at`, время последней перезагрузки ради обновления оболочки. Он живёт не дольше вкладки и задаёт предел частоты: между двумя такими перезагрузками не меньше 30 секунд. Петлю закрывает не он, а сверка версии оболочки (ADR-070).
|
||||
|
||||
## Экспорт `.bare`
|
||||
|
||||
|
||||
+23
-5
@@ -8,6 +8,8 @@
|
||||
|
||||
Без inline-стилей и inline-скриптов (CSP). Рендер — `document.createElement` и `textContent`; `innerHTML` не используется нигде: сообщения — пользовательские данные.
|
||||
|
||||
Страница ведёт себя как приложение, а не как документ (ADR-075): масштабирования нет, резинового отскока нет, выделение снято с шапки, сайдбара, кнопок и разделителей — но не с текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара: их копируют. Установленное на «Домой» приложение объявлено мета-строками, имя на экране — `bare`, системная полоса остаётся над страницей. Подвал сайдбара и строка ввода отступают от нижней системной полосы iPhone; у строки весь нижний отступ становится 6 px, пока край уже закрыт экранной клавиатурой (ADR-076).
|
||||
|
||||
## Вход и регистрация
|
||||
|
||||
Одна страница, два режима переключателем «вход / регистрация». Логотип-знак и `bare` сверху.
|
||||
@@ -24,19 +26,29 @@
|
||||
|
||||
Секции «каналы» и «личные», как в моке. Активный чат — инверсия (ink на bone). Непрочитанные — число цветом `mark` справа. Порядок — по `lastId` по убыванию. Внизу — «ты: @nick», по нажатию — настройки. Над секциями — строка `+ новый чат`.
|
||||
|
||||
В подвале рядом с «ты: @nick» — версия и время коммита из `GET /api/config`: `версия · дд.мм чч:мм`, время в местной зоне, цветом `stone`, размером подписи. Не кнопка, но выделяется: её копируют. Времени нет — остаётся версия; конфигурации ещё нет — строки нет. В сайдбаре 224 px рядом они не помещаются: версия занимает вторую строку и стоит справа; на мобильном, где сайдбар во всю ширину, обе стоят в одной строке. Ник, которому не хватило места, обрезается многоточием (ADR-067).
|
||||
|
||||
Версия — серверная. Оболочка в браузере бывает старше: она приезжает из кэша, а обновление применяется отдельно и ждёт пустых полей (ADR-068).
|
||||
|
||||
## Новый чат (`#/new`)
|
||||
|
||||
Две строки ввода: `@ник` → открыть личный чат; `#имя комнаты` → создать комнату. Ошибки: «такого ника нет», «нельзя писать себе».
|
||||
Один экран с переключателем двух взаимоисключающих действий: «личный чат / новая комната». Видна только форма выбранного действия; начальный режим — личный чат, но поле само не получает фокус.
|
||||
|
||||
«Личный чат»: текст «напишите человеку по нику.», поле «ник человека» с подсказкой `@ник`, кнопка «написать». Ошибки: «такого ника нет», «нельзя писать себе».
|
||||
|
||||
«Новая комната»: текст «создайте новую приватную комнату. сначала в ней будете только вы; участников добавите следующим шагом.» и пояснение «если вас добавят в чужую комнату, она появится в списке сама.». Поле «название новой комнаты» с подсказкой `#название`, кнопка «создать комнату». Имя не ищет и не открывает существующую комнату: оно не уникально, а доступ даёт владелец добавлением по нику. После создания открывается экран участников новой комнаты (ADR-078).
|
||||
|
||||
## Чат
|
||||
|
||||
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн».
|
||||
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. У комнаты в той же кнопке стоит подпись «· участники N»; это число состава, не присутствующих онлайн. Без темы и «N онлайн».
|
||||
|
||||
Лента: десктоп — сетка «автор 132 px + текст», подряд идущие сообщения одного автора — без повтора автора; мобильный — автор над группой. Свой ник в колонке автора — цветом `mark`. Разделители дат — линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
|
||||
Лента: поток блоков сверху вниз; подряд идущие сообщения одного автора — без повтора автора над группой. На телефоне и планшете свои сообщения выровнены по правому краю, чужие — по левому; на десктопе все сообщения стоят слева (ADR-077). Десктоп определяется точным указателем с наведением, а не шириной окна: широкий планшет остаётся планшетом. Свой ник остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока всегда выровнен по левому краю. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
|
||||
|
||||
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
|
||||
|
||||
Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500.
|
||||
Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; нативный `maxLength` не пускает лишнее. Тап по неинтерактивному месту истории снимает фокус со строки и закрывает клавиатуру. Пока открыта экранная клавиатура, весь нижний отступ строки заменяется 6 px; при физической клавиатуре и после закрытия обычный отступ сохраняется (ADR-076).
|
||||
|
||||
Строка сообщения — `textarea` (ADR-076): она принимает только текст, сама держит вставку, выделение, отмену, подсказку, предел и состояние `disabled`. Растёт под содержимое, дальше семи строк — прокрутка. Системную панель со стрелками и «готово» iOS рисует поверх клавиатуры и для `textarea`, и для `contenteditable`; у веб-страницы нет поддерживаемого API, чтобы её скрыть. Полное управление этой панелью потребовало бы отдельной нативной iOS-оболочки; собственная экранная клавиатура в веб-клиенте не делается.
|
||||
|
||||
Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038).
|
||||
|
||||
@@ -54,7 +66,7 @@
|
||||
|
||||
## Участники (`#/room/<id>/members`)
|
||||
|
||||
Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
|
||||
Заголовок «участники» и список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
|
||||
|
||||
## Настройки (`#/settings`)
|
||||
|
||||
@@ -85,6 +97,8 @@
|
||||
- Вкладок одного профиля бывает несколько; поток событий держит одна из них, остальные получают изменения от неё и выглядят так же (ADR-035).
|
||||
- Без сети: полоса «нет соединения» цветом `stone` над вводом; ввод не блокируется — сообщения уходят в `pending`.
|
||||
- `clock_skew` — «проверьте часы на устройстве: расхождение больше 5 минут».
|
||||
- Приложение обновляется само: клиент спрашивает сервер о новой оболочке при запуске и при возвращении в приложение, не чаще раза в минуту, включает установленную версию и перезагружает страницу. Перезагрузка вкладки ждёт, пока пусты все её поля и строка сообщения: набранное она бы унесла. Опустевшее поле она замечает и там, где о нём не сказало ни одно событие — отправленное сообщение, уход с экрана: к отложенному вкладка возвращается по таймеру (ADR-072). Вкладок бывает несколько (ADR-035), и каждая ждёт своих полей: соседняя может включить новую оболочку раньше, но перезагрузиться под набранным текстом не заставит. Без сети проверка проходит молча (ADR-068).
|
||||
- Перезагружается вкладка только тогда, когда версия оболочки и правда сменилась. Тот же файл воркера, отданный сервером заново, её не трогает: перезагрузка вернула бы ту же страницу. Между перезагрузками — не меньше 30 секунд; сколько бы релизов ни вышло за жизнь вкладки, доезжают все (ADR-070).
|
||||
- `401 unauthenticated` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). Исключение одно: служебный выход перед повторным входом при смене пароля и удалении аккаунта (ADR-031) — там этот ответ означает, что сессии и так нет.
|
||||
|
||||
## Тексты состояний
|
||||
@@ -107,3 +121,7 @@
|
||||
## Доступность
|
||||
|
||||
Семантика: `nav`, `main`, `form`, `button`, `ul/li` для списков; `aria-live="polite"` на ленте; фокус в строку ввода при открытии чата на десктопе; контраст ink/bone и mark/bone не ниже 4.5:1; цели нажатия на мобильном не меньше 44 px.
|
||||
|
||||
Строка сообщения — нативная `textarea` (ADR-076): роль многострочного поля и состояние `disabled` сообщает сам браузер; доступное имя повторяет текст видимой подсказки через `aria-label`, кнопка `>` объявляется как «отправить».
|
||||
|
||||
Масштабирование запрещено — и щипок, и двойной тап (ADR-075). Это сознательный размен: интерфейс ведёт себя как приложение, а человек, которому нужно увеличить мелкое, средства лишается. Размеры шрифтов от запрета не меняются: поля ввода остаются 14 px, как в `docs/identity/brief.md`. Браузер вправе запрет проигнорировать.
|
||||
|
||||
@@ -8,22 +8,33 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/build"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
// GET /api/config — то, что клиенту нужно знать до входа.
|
||||
//
|
||||
// version и commitAt отвечают на вопрос «какой код сейчас работает»:
|
||||
// короткая ревизия сборки и время коммита, а не момент компиляции
|
||||
// (ADR-074). Отдельного эндпоинта им не заводится — конфигурацию клиент
|
||||
// читает до входа и так.
|
||||
func (s *server) config(w http.ResponseWriter, r *http.Request) {
|
||||
info := build.Current()
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
InviteRequired bool `json:"inviteRequired"`
|
||||
VAPIDPublicKey string `json:"vapidPublicKey"`
|
||||
KDFIterations int `json:"kdfIterations"`
|
||||
MaxMessageChars int `json:"maxMessageChars"`
|
||||
Version string `json:"version"`
|
||||
CommitAt int64 `json:"commitAt"`
|
||||
}{
|
||||
InviteRequired: s.cfg.InviteCode != "",
|
||||
VAPIDPublicKey: s.cfg.VAPIDPublic,
|
||||
KDFIterations: config.KDFIterations,
|
||||
MaxMessageChars: config.MaxMessageChars,
|
||||
Version: info.Version(),
|
||||
CommitAt: info.CommitMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/api"
|
||||
"github.com/xmatic-squad/bare/internal/build"
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
)
|
||||
|
||||
@@ -80,8 +81,24 @@ func TestConfig(t *testing.T) {
|
||||
VAPIDPublicKey string `json:"vapidPublicKey"`
|
||||
KDFIterations int `json:"kdfIterations"`
|
||||
MaxMessageChars int `json:"maxMessageChars"`
|
||||
Version string `json:"version"`
|
||||
CommitAt int64 `json:"commitAt"`
|
||||
}
|
||||
decodeBody(t, rec, &got)
|
||||
// Имена полей — часть протокола (docs/protocol.md). Сравнение значений
|
||||
// их не закрепляет: у половины полей нулевое значение законно, и ответ
|
||||
// без поля разбирается в тот же ноль — переименованный тег прошёл бы
|
||||
// незамеченным. Поэтому сначала перечень ключей, потом значения.
|
||||
var keys map[string]json.RawMessage
|
||||
decodeBody(t, rec, &keys)
|
||||
for _, name := range []string{
|
||||
"inviteRequired", "vapidPublicKey", "kdfIterations",
|
||||
"maxMessageChars", "version", "commitAt",
|
||||
} {
|
||||
if _, ok := keys[name]; !ok {
|
||||
t.Errorf("в ответе нет поля %q", name)
|
||||
}
|
||||
}
|
||||
if got.InviteRequired {
|
||||
t.Error("inviteRequired: получено true, ожидалось false")
|
||||
}
|
||||
@@ -94,6 +111,40 @@ func TestConfig(t *testing.T) {
|
||||
if got.MaxMessageChars != 4000 {
|
||||
t.Errorf("maxMessageChars: получено %d, ожидалось 4000", got.MaxMessageChars)
|
||||
}
|
||||
// Версия — то же самое, что печатает `bare version`: одно место,
|
||||
// один формат (ADR-074). Тестовому бинарю vcs.* не проставляются,
|
||||
// поэтому здесь проверяется в том числе поведение без build info —
|
||||
// «unknown» и 0.
|
||||
if !validVersion(got.Version) {
|
||||
t.Errorf("version: получено %q, ожидались до семи hex-символов, «+dirty» или «unknown»", got.Version)
|
||||
}
|
||||
if want := build.Current().Version(); got.Version != want {
|
||||
t.Errorf("version: получено %q, ожидалось %q", got.Version, want)
|
||||
}
|
||||
if want := build.Current().CommitMilli(); got.CommitAt != want {
|
||||
t.Errorf("commitAt: получено %d, ожидалось %d", got.CommitAt, want)
|
||||
}
|
||||
if got.CommitAt < 0 {
|
||||
t.Errorf("commitAt: получено %d, неизвестное время — это 0", got.CommitAt)
|
||||
}
|
||||
}
|
||||
|
||||
// validVersion — формат поля version: «unknown» либо до семи символов
|
||||
// хеша, у сборки из изменённого дерева с суффиксом «+dirty».
|
||||
func validVersion(v string) bool {
|
||||
if v == "unknown" {
|
||||
return true
|
||||
}
|
||||
rev, _ := strings.CutSuffix(v, "+dirty")
|
||||
if rev == "" || len(rev) > 7 {
|
||||
return false
|
||||
}
|
||||
for _, c := range rev {
|
||||
if !strings.ContainsRune("0123456789abcdef", c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestRegisterAndLogin(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package build отвечает на вопрос «какой код сейчас работает»: ревизия
|
||||
// и время коммита, которые git оставляет в бинаре при сборке (ADR-074).
|
||||
// Момента компиляции здесь нет и не будет: штамп времени сборки делал бы
|
||||
// каждую пересборку одного коммита новым файлом, а подлинность бинаря
|
||||
// проверяется сравнением хеша со сборкой из тега (ADR-022).
|
||||
package build
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
"time"
|
||||
)
|
||||
|
||||
// short — сколько символов ревизии показываются человеку и клиенту.
|
||||
// Полный хеш не добавляет ничего: коммит опознаётся и по семи.
|
||||
const short = 7
|
||||
|
||||
// unknown — ревизии нет вовсе (ADR-057).
|
||||
const unknown = "unknown"
|
||||
|
||||
// Info — что бинарь знает о своём происхождении. Нулевое значение —
|
||||
// сборка не из git: так выглядят `go run`, `go build -buildvcs=false`
|
||||
// и тестовый бинарь.
|
||||
type Info struct {
|
||||
Revision string // vcs.revision — полный хеш коммита; пусто, если неизвестен
|
||||
Modified bool // vcs.modified — дерево при сборке было изменено (ADR-057)
|
||||
CommitAt time.Time // vcs.time — время коммита; нулевое, если неизвестно
|
||||
}
|
||||
|
||||
// current читается один раз: у собранного бинаря это неизменная величина.
|
||||
var current = read(debug.ReadBuildInfo())
|
||||
|
||||
// Current — сведения о текущей сборке.
|
||||
func Current() Info { return current }
|
||||
|
||||
// Version — ревизия строкой: семь символов хеша, «+dirty» у сборки
|
||||
// из изменённого дерева (ADR-057), «unknown» без ревизии вовсе.
|
||||
func (i Info) Version() string {
|
||||
if i.Revision == "" {
|
||||
return unknown
|
||||
}
|
||||
v := i.Revision
|
||||
if len(v) > short {
|
||||
v = v[:short]
|
||||
}
|
||||
if i.Modified {
|
||||
v += "+dirty"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// CommitMilli — время коммита в миллисекундах Unix, 0 если его нет.
|
||||
// Проверка отдельная: UnixMilli нулевого времени даёт не ноль, а большое
|
||||
// отрицательное число.
|
||||
func (i Info) CommitMilli() int64 {
|
||||
if i.CommitAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return i.CommitAt.UnixMilli()
|
||||
}
|
||||
|
||||
// read разбирает build info. Отдельная функция принимает результат
|
||||
// debug.ReadBuildInfo как есть: только так поведение без build info
|
||||
// и с неразбираемыми значениями проверяется тестом — тестовому бинарю
|
||||
// vcs.* не проставляются (ADR-057).
|
||||
func read(info *debug.BuildInfo, ok bool) Info {
|
||||
if !ok || info == nil {
|
||||
return Info{}
|
||||
}
|
||||
var out Info
|
||||
for _, s := range info.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
out.Revision = s.Value
|
||||
case "vcs.modified":
|
||||
out.Modified = s.Value == "true"
|
||||
case "vcs.time":
|
||||
// Неразобранное время равносильно его отсутствию: соврать
|
||||
// о нём хуже, чем промолчать.
|
||||
if t, err := time.Parse(time.RFC3339, s.Value); err == nil {
|
||||
out.CommitAt = t
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const hash = "9f2c1ab7d3e4c5061728394a5b6c7d8e9f001122"
|
||||
|
||||
// Формат версии: семь символов ревизии, «+dirty» у изменённого дерева,
|
||||
// «unknown» без ревизии (ADR-057, ADR-074).
|
||||
func TestVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
info Info
|
||||
want string
|
||||
}{
|
||||
{Info{Revision: hash}, "9f2c1ab"},
|
||||
{Info{Revision: hash, Modified: true}, "9f2c1ab+dirty"},
|
||||
// Ревизия короче семи символов остаётся как есть.
|
||||
{Info{Revision: "9f2c"}, "9f2c"},
|
||||
{Info{Revision: "9f2c", Modified: true}, "9f2c+dirty"},
|
||||
// Сборка не из git: «unknown» и без «+dirty» — помечать нечего.
|
||||
{Info{}, unknown},
|
||||
{Info{Modified: true}, unknown},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.info.Version(); got != c.want {
|
||||
t.Errorf("Version() для %+v: получено %q, ожидалось %q", c.info, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Время коммита — миллисекунды Unix; неизвестное время даёт ноль,
|
||||
// а не большое отрицательное число.
|
||||
func TestCommitMilli(t *testing.T) {
|
||||
at := time.Date(2026, 8, 22, 9, 14, 3, 0, time.UTC)
|
||||
if got := (Info{CommitAt: at}).CommitMilli(); got != at.UnixMilli() {
|
||||
t.Errorf("CommitMilli(): получено %d, ожидалось %d", got, at.UnixMilli())
|
||||
}
|
||||
if got := (Info{}).CommitMilli(); got != 0 {
|
||||
t.Errorf("CommitMilli() без времени: получено %d, ожидался 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRead(t *testing.T) {
|
||||
at := time.Date(2026, 8, 22, 9, 14, 3, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
settings []debug.BuildSetting
|
||||
want Info
|
||||
}{
|
||||
{
|
||||
name: "чистое дерево",
|
||||
settings: []debug.BuildSetting{
|
||||
{Key: "-compiler", Value: "gc"},
|
||||
{Key: "vcs", Value: "git"},
|
||||
{Key: "vcs.revision", Value: hash},
|
||||
{Key: "vcs.time", Value: "2026-08-22T09:14:03Z"},
|
||||
{Key: "vcs.modified", Value: "false"},
|
||||
},
|
||||
want: Info{Revision: hash, CommitAt: at},
|
||||
},
|
||||
{
|
||||
name: "изменённое дерево",
|
||||
settings: []debug.BuildSetting{
|
||||
{Key: "vcs.revision", Value: hash},
|
||||
{Key: "vcs.time", Value: "2026-08-22T09:14:03Z"},
|
||||
{Key: "vcs.modified", Value: "true"},
|
||||
},
|
||||
want: Info{Revision: hash, Modified: true, CommitAt: at},
|
||||
},
|
||||
{
|
||||
// Тестовый бинарь и `go build -buildvcs=false` выглядят так.
|
||||
name: "build info без vcs",
|
||||
settings: []debug.BuildSetting{{Key: "-compiler", Value: "gc"}, {Key: "GOOS", Value: "linux"}},
|
||||
want: Info{},
|
||||
},
|
||||
{
|
||||
// Время не по RFC 3339 — то же, что его отсутствие.
|
||||
name: "время не разбирается",
|
||||
settings: []debug.BuildSetting{
|
||||
{Key: "vcs.revision", Value: hash},
|
||||
{Key: "vcs.time", Value: "вчера"},
|
||||
},
|
||||
want: Info{Revision: hash},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := read(&debug.BuildInfo{Settings: c.settings}, true)
|
||||
if got.Revision != c.want.Revision || got.Modified != c.want.Modified || !got.CommitAt.Equal(c.want.CommitAt) {
|
||||
t.Errorf("read(%s): получено %+v, ожидалось %+v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build info недоступен вовсе: ревизия — «unknown», время — 0.
|
||||
// Это же состояние у любой сборки не из git, включая тестовую.
|
||||
func TestReadWithoutBuildInfo(t *testing.T) {
|
||||
for _, got := range []Info{read(nil, false), read(nil, true), read(&debug.BuildInfo{}, true)} {
|
||||
if got != (Info{}) {
|
||||
t.Errorf("получено %+v, ожидалось нулевое", got)
|
||||
}
|
||||
if v := got.Version(); v != unknown {
|
||||
t.Errorf("Version(): получено %q, ожидалось %q", v, unknown)
|
||||
}
|
||||
if ms := got.CommitMilli(); ms != 0 {
|
||||
t.Errorf("CommitMilli(): получено %d, ожидался 0", ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,47 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("%s пуст: уберите переменную, чтобы взять значение по умолчанию, или задайте непустое", v.key)
|
||||
}
|
||||
}
|
||||
if err := checkVAPIDSubject(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// checkVAPIDSubject проверяет форму VAPID-субъекта при старте. Ошибка
|
||||
// в нём иначе не видна вовсе: сервер поднимается, подписки ставятся,
|
||||
// а push-сервис отвергает каждый токен — «пуши не приходят» без единой
|
||||
// строки о причине. Отказ на старте дешевле.
|
||||
//
|
||||
// Годится ровно то, что допускает RFC 8292: «mailto:<адрес>» или
|
||||
// «https://<хост>» (docs/deploy.md).
|
||||
func checkVAPIDSubject(c *Config) error {
|
||||
if c.VAPIDSubject == "" {
|
||||
// Без ключей пуши и так выключены — это рабочий локальный запуск.
|
||||
// А вот ключи без субъекта означают, что настроить пуши хотели
|
||||
// и не настроили.
|
||||
if c.VAPIDPublic == "" && c.VAPIDPrivate == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("BARE_VAPID_SUBJECT пуст при заданных VAPID-ключах: нужен mailto:<адрес> или https://<хост>")
|
||||
}
|
||||
bad := fmt.Errorf("BARE_VAPID_SUBJECT=%q не годится: нужен mailto:<адрес> или https://<хост>", c.VAPIDSubject)
|
||||
if addr, ok := strings.CutPrefix(c.VAPIDSubject, "mailto:"); ok {
|
||||
local, domain, at := strings.Cut(addr, "@")
|
||||
if !at || local == "" || domain == "" || strings.ContainsAny(addr, " \t") {
|
||||
return bad
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(c.VAPIDSubject, "https://"); ok {
|
||||
host, _, _ := strings.Cut(rest, "/")
|
||||
if host == "" || strings.ContainsAny(rest, " \t") {
|
||||
return bad
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return bad
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return strings.TrimSpace(v)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Форма VAPID-субъекта проверяется на старте: с мусором в нём сервер
|
||||
// поднимается, подписки ставятся, а push-сервис отвергает каждый токен —
|
||||
// поломка без единой строки в журнале (docs/deploy.md).
|
||||
func TestVAPIDSubject(t *testing.T) {
|
||||
cases := []struct {
|
||||
subject string
|
||||
keys bool
|
||||
ok bool
|
||||
}{
|
||||
{"mailto:admin@xmatic.team", true, true},
|
||||
{"https://bare.xmatic.team", true, true},
|
||||
{"https://bare.xmatic.team/", true, true},
|
||||
// Голый адрес — не URI: RFC 8292 требует схему, и APNs отвергает
|
||||
// токен без неё.
|
||||
{"admin@xmatic.team", true, false},
|
||||
{"mailto:", true, false},
|
||||
{"mailto:admin", true, false},
|
||||
{"mailto:@xmatic.team", true, false},
|
||||
{"mailto:admin@xmatic.team, second@xmatic.team", true, false},
|
||||
{"https://", true, false},
|
||||
{"http://bare.xmatic.team", true, false},
|
||||
{"bare.xmatic.team", true, false},
|
||||
// Ключи заданы, субъекта нет: пуши настроить хотели и не настроили.
|
||||
{"", true, false},
|
||||
// Ни ключей, ни субъекта — локальный запуск без пушей.
|
||||
{"", false, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Setenv("BARE_VAPID_SUBJECT", c.subject)
|
||||
if c.keys {
|
||||
t.Setenv("BARE_VAPID_PUBLIC", "public")
|
||||
t.Setenv("BARE_VAPID_PRIVATE", "private")
|
||||
} else {
|
||||
t.Setenv("BARE_VAPID_PUBLIC", "")
|
||||
t.Setenv("BARE_VAPID_PRIVATE", "")
|
||||
}
|
||||
_, err := Load()
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("BARE_VAPID_SUBJECT=%q (ключи: %v): %v", c.subject, c.keys, err)
|
||||
}
|
||||
if !c.ok {
|
||||
if err == nil {
|
||||
t.Errorf("BARE_VAPID_SUBJECT=%q (ключи: %v): принят", c.subject, c.keys)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "BARE_VAPID_SUBJECT") {
|
||||
t.Errorf("ошибка не называет переменную: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
-7
@@ -15,6 +15,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -69,6 +70,16 @@ const (
|
||||
dropEvery = time.Minute
|
||||
)
|
||||
|
||||
// Чтение тела ответа push-сервиса ради кода причины (ADR-073).
|
||||
const (
|
||||
// maxReasonBody — сколько байт тела читаем. Код причины стоит в начале
|
||||
// ответа; остальное дочитывается в никуда, ради переиспользования
|
||||
// соединения.
|
||||
maxReasonBody = 200
|
||||
// maxReason — предел длины кода причины в журнале.
|
||||
maxReason = 64
|
||||
)
|
||||
|
||||
// Devices — что отправителю нужно от хранилища. Правило «одно молчащее
|
||||
// устройство — один пуш» держится на атомарном захвате (ADR-023).
|
||||
type Devices interface {
|
||||
@@ -110,9 +121,11 @@ type Sender struct {
|
||||
connected func(device string) bool
|
||||
public string
|
||||
private string
|
||||
subject string
|
||||
client *http.Client
|
||||
logw io.Writer
|
||||
// subject — VAPID-субъект в той форме, которую ждёт webpush-go:
|
||||
// у «mailto:» схема снята, см. vapidSubscriber.
|
||||
subject string
|
||||
client *http.Client
|
||||
logw io.Writer
|
||||
|
||||
jobs chan job
|
||||
done chan struct{}
|
||||
@@ -149,7 +162,7 @@ func New(cfg *config.Config, devices Devices, connected func(device string) bool
|
||||
connected: connected,
|
||||
public: cfg.VAPIDPublic,
|
||||
private: cfg.VAPIDPrivate,
|
||||
subject: cfg.VAPIDSubject,
|
||||
subject: vapidSubscriber(cfg.VAPIDSubject),
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
// Push-сервисы редиректов не шлют. Следование за ними
|
||||
@@ -180,6 +193,35 @@ func (s *Sender) on() bool {
|
||||
return s.public != "" && s.private != "" && s.subject != ""
|
||||
}
|
||||
|
||||
// vapidSubscriber приводит BARE_VAPID_SUBJECT к форме, которую ждёт
|
||||
// webpush-go. Нормализация здесь не косметика: без неё пуши не уходят
|
||||
// вовсе, ни на одной платформе.
|
||||
//
|
||||
// По RFC 8292 поле sub в VAPID-токене — это URI: «mailto:<адрес>» или
|
||||
// «https://<хост>». Именно так субъект и записан в окружении
|
||||
// (docs/deploy.md), и менять запись нельзя — она верна. Но webpush-go
|
||||
// в getVAPIDAuthorizationHeader считает субъектом голый адрес и сам
|
||||
// приписывает схему всему, что не начинается с «https:»:
|
||||
//
|
||||
// if !strings.HasPrefix(subscriber, "https:") {
|
||||
// subscriber = "mailto:" + subscriber
|
||||
// }
|
||||
//
|
||||
// Готовый «mailto:admin@example.org» превращается в
|
||||
// «mailto:mailto:admin@example.org», и push-сервис отвергает токен:
|
||||
// APNs отвечает 403 BadJwtToken на каждый пуш. Обойти это настройкой
|
||||
// нельзя — голый адрес в sub тот же APNs тоже отвергает 403. Поэтому
|
||||
// схему снимаем ровно перед вызовом библиотеки: в токен она вернётся,
|
||||
// а конфигурация остаётся правильной по спецификации.
|
||||
//
|
||||
// «https:» отдаётся как есть: его библиотека узнаёт и не трогает.
|
||||
func vapidSubscriber(subject string) string {
|
||||
if strings.HasPrefix(subject, "https:") {
|
||||
return subject
|
||||
}
|
||||
return strings.TrimPrefix(subject, "mailto:")
|
||||
}
|
||||
|
||||
// Send ставит пуш каждому из устройств в очередь отправки и возвращается
|
||||
// сразу: конверт уже в очереди устройства, ответ на POST /api/messages
|
||||
// пуша не ждёт (ADR-023).
|
||||
@@ -289,8 +331,9 @@ func (s *Sender) deliver(j job) {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Тело ответа push-сервиса нам не нужно, но дочитать его стоит:
|
||||
// иначе соединение не переиспользуется.
|
||||
// Начало тела нужно ради кода причины (ADR-073), остаток дочитывается
|
||||
// в никуда: иначе соединение не переиспользуется.
|
||||
head, _ := io.ReadAll(io.LimitReader(resp.Body, maxReasonBody))
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
switch {
|
||||
@@ -305,7 +348,7 @@ func (s *Sender) deliver(j job) {
|
||||
// Подписки больше нет — чистим мёртвую (ADR-011).
|
||||
s.drop(j.device)
|
||||
default:
|
||||
s.report("push-сервис ответил %d", resp.StatusCode)
|
||||
s.report("push-сервис ответил %s", status(resp.StatusCode, head))
|
||||
s.release(j.device)
|
||||
}
|
||||
}
|
||||
@@ -388,6 +431,59 @@ func (s *Sender) report(format string, args ...any) {
|
||||
fmt.Fprintf(s.logw, "%s пуш: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// status — ответ push-сервиса для журнала: код и, если он разобран,
|
||||
// короткий код причины из тела (ADR-073).
|
||||
func status(code int, body []byte) string {
|
||||
if r := serviceReason(body); r != "" {
|
||||
return fmt.Sprintf("%d (%s)", code, r)
|
||||
}
|
||||
return fmt.Sprintf("%d", code)
|
||||
}
|
||||
|
||||
// serviceReason достаёт из тела ответа короткий код причины: APNs отвечает
|
||||
// {"reason":"BadJwtToken"}, Mozilla — {"errno":…,"error":"Not Found"}.
|
||||
// Код — диагностика вендора, а не данные пользователя, и без него отказ
|
||||
// не читается: голый «403» сутки выглядел как «что-то с пушами» (ADR-073).
|
||||
//
|
||||
// Тело всё же приходит снаружи, поэтому в журнал идёт не оно, а то, что
|
||||
// прошло safeReason.
|
||||
func serviceReason(body []byte) string {
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &fields) == nil {
|
||||
for _, key := range []string{"reason", "error", "message"} {
|
||||
var s string
|
||||
if json.Unmarshal(fields[key], &s) == nil && s != "" {
|
||||
return safeReason(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return safeReason(string(body))
|
||||
}
|
||||
|
||||
// safeReason пропускает только то, что заведомо является кодом причины:
|
||||
// одна строка не длиннее maxReason из латиницы, цифр, «_», «-» и пробелов.
|
||||
// Точка, «:», «/» и «@» встречаются в адресах подписки и именах хостов,
|
||||
// поэтому строка с ними отбрасывается целиком — в журнале остаётся один
|
||||
// статус (docs/deploy.md, «Логи»).
|
||||
func safeReason(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
if s == "" || len(s) > maxReason {
|
||||
return ""
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == ' ', r == '_', r == '-':
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// errLocalAddress — попытка соединиться с непубличным адресом (ADR-047).
|
||||
var errLocalAddress = errors.New("push: адрес не публичный")
|
||||
|
||||
|
||||
@@ -2,15 +2,22 @@ package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
)
|
||||
@@ -143,3 +150,246 @@ func TestReasonWithoutEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// В VAPID-токен уходит субъект ровно в той форме, которую требует
|
||||
// RFC 8292: у «mailto:» одна схема, а не две. webpush-go приписывает
|
||||
// «mailto:» всему, что не начинается с «https:», поэтому готовый URI
|
||||
// приходится отдавать ему без схемы — см. vapidSubscriber. Без этого
|
||||
// APNs отвечает 403 BadJwtToken на каждый пуш.
|
||||
func TestVAPIDSubjectInToken(t *testing.T) {
|
||||
cases := []struct{ subject, want string }{
|
||||
{"mailto:admin@xmatic.team", "mailto:admin@xmatic.team"},
|
||||
{"https://bare.xmatic.team", "https://bare.xmatic.team"},
|
||||
{"admin@xmatic.team", "mailto:admin@xmatic.team"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.subject, func(t *testing.T) {
|
||||
got := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
select {
|
||||
case got <- r.Header.Get("Authorization"):
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := sender(t, c.subject, srv.URL, nil)
|
||||
defer s.Close()
|
||||
s.Send([]Target{{Device: "d1", Owner: "marta"}}, Payload{Title: "@marta", Chat: "dm:marta"})
|
||||
|
||||
var header string
|
||||
select {
|
||||
case header = <-got:
|
||||
case <-time.After(wait):
|
||||
t.Fatal("push-сервис не получил запроса")
|
||||
}
|
||||
if sub := subClaim(t, header); sub != c.want {
|
||||
t.Errorf("sub: получено %q, ожидалось %q", sub, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ push-сервиса читается по журналу: статус и код причины из тела
|
||||
// (ADR-073). Без кода 403 от APNs неотличим от любого другого отказа.
|
||||
func TestServiceStatusInLog(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, `{"reason":"BadJwtToken"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
log := &logbuf{}
|
||||
s, d := sender(t, "mailto:admin@xmatic.team", srv.URL, log)
|
||||
defer s.Close()
|
||||
s.Send([]Target{{Device: "d1", Owner: "marta"}}, Payload{Title: "@marta", Chat: "dm:marta"})
|
||||
|
||||
const want = "пуш: push-сервис ответил 403 (BadJwtToken)"
|
||||
deadline := time.Now().Add(wait)
|
||||
for !strings.Contains(log.String(), want) && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if line := log.String(); !strings.Contains(line, want) {
|
||||
t.Errorf("журнал: %q, ожидалась строка %q", line, want)
|
||||
}
|
||||
if strings.Contains(log.String(), srv.URL) || strings.Contains(log.String(), "d1") {
|
||||
t.Errorf("в журнал попал адрес подписки или устройство: %q", log.String())
|
||||
}
|
||||
if d.drops() != 0 {
|
||||
t.Error("подписка снята по ответу 403")
|
||||
}
|
||||
}
|
||||
|
||||
// Код причины берётся из тела ответа, но телом распоряжается чужая
|
||||
// сторона: всё, что на короткий код не похоже, в журнал не идёт вовсе
|
||||
// (ADR-073, docs/deploy.md, «Логи»).
|
||||
func TestServiceReason(t *testing.T) {
|
||||
const endpoint = "web.push.apple.com"
|
||||
cases := []struct{ body, want string }{
|
||||
{`{"reason":"BadJwtToken"}`, "BadJwtToken"},
|
||||
{`{"reason":"TooManyRequests"}`, "TooManyRequests"},
|
||||
{`{"code":404,"errno":103,"error":"Not Found"}`, "Not Found"},
|
||||
{"Unauthorized registration", "Unauthorized registration"},
|
||||
{`{"error":{"code":403,"status":"UNAUTHENTICATED"}}`, ""},
|
||||
{`{"reason":"unknown push endpoint https://` + endpoint + `/QK"}`, ""},
|
||||
{"gone: " + endpoint, ""},
|
||||
{`{"message":"subscription ` + endpoint + ` expired"}`, ""},
|
||||
{strings.Repeat("A", maxReason+1), ""},
|
||||
{"", ""},
|
||||
// Многострочное тело: в журнал идёт первая строка, остальное
|
||||
// отбрасывается вместе с адресом.
|
||||
{"BadJwtToken\nendpoint: " + endpoint, "BadJwtToken"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := serviceReason([]byte(c.body))
|
||||
if got != c.want {
|
||||
t.Errorf("serviceReason(%q): получено %q, ожидалось %q", c.body, got, c.want)
|
||||
}
|
||||
if strings.Contains(got, endpoint) {
|
||||
t.Errorf("адрес подписки попал в журнал: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Статус без разобранного кода причины остаётся статусом.
|
||||
func TestStatusWithoutReason(t *testing.T) {
|
||||
if got := status(500, nil); got != "500" {
|
||||
t.Errorf("status: получено %q, ожидалось %q", got, "500")
|
||||
}
|
||||
if got := status(403, []byte(`{"reason":"BadJwtToken"}`)); got != "403 (BadJwtToken)" {
|
||||
t.Errorf("status: получено %q, ожидалось %q", got, "403 (BadJwtToken)")
|
||||
}
|
||||
}
|
||||
|
||||
// wait — сколько ждём отправку. Всё локально, задержек быть не должно.
|
||||
const wait = 5 * time.Second
|
||||
|
||||
// sender — отправитель с настоящей парой VAPID-ключей и одной подпиской
|
||||
// на подменный push-сервис. Без ключей отправщики не заводятся.
|
||||
func sender(t *testing.T, subject, endpoint string, logw io.Writer) (*Sender, *devices) {
|
||||
t.Helper()
|
||||
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("vapid: %v", err)
|
||||
}
|
||||
d := &devices{subscription: subscription(t, endpoint)}
|
||||
cfg := &config.Config{
|
||||
VAPIDPublic: base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()),
|
||||
VAPIDPrivate: base64.RawURLEncoding.EncodeToString(key.Bytes()),
|
||||
VAPIDSubject: subject,
|
||||
// Подменный push-сервис живёт на 127.0.0.1; в работе отправщик
|
||||
// ходит только по публичным адресам (ADR-047).
|
||||
PushLocal: true,
|
||||
}
|
||||
return New(cfg, d, nil, logw), d
|
||||
}
|
||||
|
||||
// subscription — подписка устройства с настоящими ключами: webpush-go
|
||||
// шифрует ими нагрузку, случайных байт ему мало.
|
||||
func subscription(t *testing.T, endpoint string) string {
|
||||
t.Helper()
|
||||
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ключ подписки: %v", err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err := rand.Read(auth); err != nil {
|
||||
t.Fatalf("секрет подписки: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"endpoint": endpoint + "/push",
|
||||
"keys": map[string]string{
|
||||
"p256dh": base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()),
|
||||
"auth": base64.RawURLEncoding.EncodeToString(auth),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("подписка: %v", err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// subClaim достаёт sub из VAPID-заголовка: «vapid t=<jwt>, k=<ключ>».
|
||||
// JWT разбирается руками — библиотека здесь и проверяется.
|
||||
func subClaim(t *testing.T, header string) string {
|
||||
t.Helper()
|
||||
rest, ok := strings.CutPrefix(header, "vapid t=")
|
||||
if !ok {
|
||||
t.Fatalf("заголовок не vapid: %q", header)
|
||||
}
|
||||
token, _, ok := strings.Cut(rest, ",")
|
||||
if !ok {
|
||||
t.Fatalf("в заголовке нет ключа: %q", header)
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("не JWT: %q", token)
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
t.Fatalf("claims: %v", err)
|
||||
}
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||
t.Fatalf("claims: %v", err)
|
||||
}
|
||||
return claims.Sub
|
||||
}
|
||||
|
||||
// devices — хранилище устройств в тесте: право на пуш даётся всегда,
|
||||
// возвраты и снятия считаются.
|
||||
type devices struct {
|
||||
subscription string
|
||||
|
||||
mu sync.Mutex
|
||||
released int
|
||||
dropcount int
|
||||
}
|
||||
|
||||
func (d *devices) ClaimPush(context.Context, string) (string, bool, error) {
|
||||
return d.subscription, true, nil
|
||||
}
|
||||
|
||||
func (d *devices) ReleasePush(context.Context, string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.released++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *devices) DropPush(context.Context, string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.dropcount++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *devices) drops() int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.dropcount
|
||||
}
|
||||
|
||||
// logbuf — журнал теста. Пишет в него отправщик, читает тест, поэтому
|
||||
// с замком.
|
||||
type logbuf struct {
|
||||
mu sync.Mutex
|
||||
b strings.Builder
|
||||
}
|
||||
|
||||
func (l *logbuf) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.b.Write(p)
|
||||
}
|
||||
|
||||
func (l *logbuf) String() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.b.String()
|
||||
}
|
||||
|
||||
+203
-56
@@ -25,16 +25,63 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* масштабирования нет (ADR-075). manipulation снимает зум по двойному тапу
|
||||
и задержку 300 мс, которую браузер держит, ожидая второго касания;
|
||||
прокрутку и щипок правило не трогает — щипок запрещён в viewport.
|
||||
Правило стоит на html: двойной тап — жест документа, и браузер берёт
|
||||
пересечение значений всех предков */
|
||||
|
||||
html {
|
||||
touch-action: manipulation;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* виден вырез: viewport-fit=cover пускает страницу под скруглённые углы,
|
||||
под боковой вырез в альбомной ориентации и под верхнюю системную
|
||||
полосу. Полосу над страницей держит мета-строка о стиле полосы
|
||||
(ADR-075), поэтому верхняя вставка обычно нулевая и не делает ничего;
|
||||
там, где она не нулевая, шапка не уезжает под часы */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
background: var(--bone);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
overscroll-behavior: none;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* интерфейс не выделяется: долгое нажатие по шапке, списку и кнопкам
|
||||
вызывает лупу и «копировать», а копировать там нечего. Текст сообщений,
|
||||
отпечатки и поля ввода выделяются как обычно — их именно копируют
|
||||
(ADR-075) */
|
||||
|
||||
.side,
|
||||
.head,
|
||||
.brand,
|
||||
.divider,
|
||||
.input .p,
|
||||
.counter,
|
||||
.enter,
|
||||
button {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* прокрутка внутренних лент не уходит в страницу: отскок и «потянуть
|
||||
для обновления» ощущения приложения не добавляют */
|
||||
|
||||
.list,
|
||||
.body,
|
||||
.feed,
|
||||
.auth {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* #app — колонка ровно в высоту окна: от неё считают высоту экраны,
|
||||
поэтому сайдбар с «ты: @nick» стоит на месте, а прокручивается
|
||||
только содержимое */
|
||||
@@ -259,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 {
|
||||
@@ -360,16 +437,34 @@ input[type="password"] {
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
/* подвал сайдбара: «ты: @nick» и версия с временем коммита (ADR-067).
|
||||
Версия не кнопка: нажимать в ней нечего, поэтому она стоит рядом
|
||||
с кнопкой, а не внутри неё. В сайдбаре 224 px они рядом не помещаются
|
||||
никогда — версия занимает вторую строку и остаётся справа; на мобильном,
|
||||
где сайдбар во всю ширину, обе стоят в одной строке */
|
||||
|
||||
.foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px 8px;
|
||||
margin-top: auto;
|
||||
/* нижняя системная полоса iPhone проходит поверх страницы: подвал
|
||||
отступает от неё, а не прячется под ней (ADR-075) */
|
||||
padding: 6px 20px calc(6px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
margin-top: auto;
|
||||
padding: 16px 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: none;
|
||||
color: var(--mute);
|
||||
font: inherit;
|
||||
@@ -385,6 +480,30 @@ input[type="password"] {
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
/* ник бывает в 32 символа (ADR-019): он обрезается, а версия остаётся
|
||||
целой — читать половину хеша незачем */
|
||||
|
||||
.me__nick {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* версия выделяется: с неё начинается разговор о поломке (ADR-067),
|
||||
а на iPhone скопировать её иначе нечем — ни строки адреса, ни консоли
|
||||
там нет. Общий запрет выделения снят с неё так же, как с текста
|
||||
сообщений и отпечатков (ADR-075) */
|
||||
|
||||
.build {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--stone);
|
||||
font-size: 11px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -628,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;
|
||||
@@ -639,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 {
|
||||
@@ -650,19 +785,26 @@ input[type="password"] {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid {
|
||||
.feed-list {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* мобильный: автор над группой сообщений */
|
||||
.feed-list > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* строка сообщения: автор над текстом. На телефоне и планшете своё стоит
|
||||
у правого края; на десктопе все сообщения идут слева (ADR-077). */
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: min(640px, 85%);
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -671,8 +813,20 @@ input[type="password"] {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.grid > :first-child {
|
||||
margin-top: 0;
|
||||
.line--me {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* Ширина окна не отличает планшет от десктопа: iPad бывает шире ноутбука.
|
||||
Точный указатель с hover — признак десктопного способа работы; там сторона
|
||||
не помогает и только заставляет взгляд ходить через всю ленту. */
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.line--me {
|
||||
align-self: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.author {
|
||||
@@ -684,6 +838,10 @@ input[type="password"] {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.author:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.author--me {
|
||||
color: var(--mark);
|
||||
}
|
||||
@@ -734,11 +892,14 @@ input[type="password"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* разделители: дата — линией line, «новые» — линией mark */
|
||||
/* разделители: дата — линией line, «новые» — линией mark; во всю ширину
|
||||
ленты, а не по ширине сообщения (feed-list выравнивает по краю только
|
||||
.line, ADR-065) */
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 14px;
|
||||
margin: 14px 0;
|
||||
color: var(--stone);
|
||||
@@ -766,7 +927,16 @@ input[type="password"] {
|
||||
|
||||
.compose {
|
||||
flex: none;
|
||||
padding: 0 16px 20px;
|
||||
/* то же, что у подвала: строка ввода не уезжает под системную полосу */
|
||||
padding: 0 16px calc(20px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* При открытой экранной клавиатуре нижний край уже закрыт её прямоугольной
|
||||
поверхностью. Safari продолжает отдавать safe-area домашнего индикатора;
|
||||
заменяем весь нижний отступ на компактные 6 px (ADR-076). */
|
||||
|
||||
.compose.is-keyboard {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -799,7 +969,10 @@ input[type="password"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 12px 0;
|
||||
/* отступ назван: высота блока считается вместе с ним (box-sizing —
|
||||
border-box), и предел роста строки сообщения меряется от него */
|
||||
--field-pad: 12px;
|
||||
padding: var(--field-pad) 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
@@ -809,15 +982,18 @@ input[type="password"] {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* строка ввода перебивает общее правило для input: рамка здесь одна,
|
||||
у всей строки */
|
||||
/* строка сообщения — textarea (ADR-076): браузер сам держит вставку,
|
||||
выделение, отмену и предел. Число rows растёт под текст до семи строк,
|
||||
дальше поле прокручивается */
|
||||
|
||||
.input textarea.input__field {
|
||||
.input .input__field--text {
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
max-height: 7em;
|
||||
/* растёт под текст там, где браузер это умеет; где нет — прокрутка */
|
||||
field-sizing: content;
|
||||
/* семь строк — это семь высот строки плюс вертикальные отступы:
|
||||
без них предел вышел бы вдвое ниже обещанного */
|
||||
max-height: calc(7 * 1.5em + 2 * var(--field-pad));
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.input .input__field::placeholder {
|
||||
@@ -908,41 +1084,12 @@ input[type="password"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* сетка «автор 132 px + текст»: строка сообщения раскрывается
|
||||
в две ячейки сетки, поэтому display: contents */
|
||||
|
||||
.feed {
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
column-gap: 20px;
|
||||
row-gap: 6px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.line,
|
||||
.line.is-head {
|
||||
display: contents;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.author {
|
||||
padding-top: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 16px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.compose {
|
||||
padding: 0 32px 28px;
|
||||
padding: 0 32px calc(28px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -951,12 +1098,12 @@ input[type="password"] {
|
||||
}
|
||||
|
||||
.input .input__field {
|
||||
padding: 13px 0;
|
||||
--field-pad: 13px;
|
||||
}
|
||||
|
||||
/* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в
|
||||
строке `@ник`: в рамке ввода «>» остаётся один, слева
|
||||
(docs/identity/brief.md, «Компоновка») */
|
||||
/* на десктопе чат отправляет enter — кнопка `>` не нужна: в рамке
|
||||
ввода она остаётся одна, слева (docs/identity/brief.md, «Компоновка»).
|
||||
Остальные формы используют обычные текстовые `.button` */
|
||||
|
||||
.input__send {
|
||||
display: none;
|
||||
@@ -993,10 +1140,10 @@ input[type="password"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* автор стоит над группой, поэтому пустая ячейка автора не занимает
|
||||
места; на десктопе она держит колонку сетки и остаётся */
|
||||
/* строка сообщения — тоже цель нажатия не меньше 44 px (docs/ui.md,
|
||||
«Доступность»): своей высоты у блока нет, её задаёт содержимое */
|
||||
|
||||
.author:empty {
|
||||
display: none;
|
||||
.input .input__field--text {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -2,8 +2,12 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#F7F5F0">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="bare">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<title>bare</title>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
@@ -32,6 +32,7 @@ import { renderMembers } from "./ui/members.js";
|
||||
import { renderNew } from "./ui/new.js";
|
||||
import { renderSettings } from "./ui/settings.js";
|
||||
import { frame } from "./ui/shell.js";
|
||||
import { lock } from "./zoom.js";
|
||||
|
||||
// Минимальная длина пароля — ADR-013.
|
||||
const MIN_PASSWORD = 12;
|
||||
@@ -481,10 +482,18 @@ function errorText(err) {
|
||||
// --- старт -------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
// Масштабирования нет: интерфейс ведёт себя как приложение, а не как
|
||||
// страница (ADR-075). Строке viewport Safari верит не всегда, поэтому
|
||||
// страховка ставится до первого экрана.
|
||||
lock();
|
||||
db.persist();
|
||||
// Service worker ставится с первой секунды: кэш оболочки нужен и до
|
||||
// входа, а пуши приходят в него же (ADR-023). Отказ ничего не ломает.
|
||||
pwa.register();
|
||||
// Новая версия оболочки включается сама и перезагружает страницу:
|
||||
// в установленном на «Домой» приложении перезагрузить её нечем
|
||||
// (ADR-068).
|
||||
pwa.watch();
|
||||
// Первое успешно отправленное сообщение за всю историю устройства —
|
||||
// единственный повод спросить разрешение на уведомления (ADR-011);
|
||||
// «один раз» считает pwa.js.
|
||||
|
||||
+338
-2
@@ -1,5 +1,5 @@
|
||||
// PWA: service worker, подписка на пуши и установка приложения
|
||||
// (ADR-011, ADR-023, ADR-046).
|
||||
// PWA: service worker, самообновление оболочки, подписка на пуши
|
||||
// и установка приложения (ADR-011, ADR-023, ADR-046, ADR-068).
|
||||
//
|
||||
// Экраны спрашивают отсюда состояние и сюда же отдают действия; в
|
||||
// pushManager, IndexedDB и сеть они не ходят — как и с чатом, это делает
|
||||
@@ -17,6 +17,48 @@ import { deviceId } from "./sync.js";
|
||||
|
||||
const WORKER = "/sw.js";
|
||||
|
||||
// Слово, по которому service worker включает установленную версию
|
||||
// (ADR-068). Решение принимает страница, а не воркер.
|
||||
const SKIP = "skip-waiting";
|
||||
|
||||
// Слово, которым страница спрашивает воркера, какую версию оболочки он
|
||||
// держит. Этим версии и различаются: новый выпуск меняет её, а тот же файл
|
||||
// воркера, отданный сервером заново, — нет (ADR-070).
|
||||
const ASK = "version";
|
||||
|
||||
// Сколько ждать ответа о версии. Отвечают сразу — вопрос стоит одного
|
||||
// сообщения; молчит воркер прежнего выпуска, который такого вопроса
|
||||
// не знает, и тогда версия остаётся неизвестной.
|
||||
const ASK_WAIT = 3 * 1000;
|
||||
|
||||
// Как часто спрашивать сервер об обновлении: при запуске и при каждом
|
||||
// возвращении в приложение, но не чаще раза в минуту. Проверка — один
|
||||
// условный запрос за /sw.js, и дёргать сеть на каждое переключение
|
||||
// приложений незачем (ADR-068).
|
||||
const CHECK_EVERY = 60 * 1000;
|
||||
|
||||
// Сколько ждать между двумя перезагрузками ради обновления в одной
|
||||
// вкладке. Пауза постоянная и не растёт: петлю закрывает сверка версии
|
||||
// оболочки (ADR-070), а это предел частоты — на случай сервера, который
|
||||
// отдаёт разные версии на каждый запрос. Отметка живёт в sessionStorage:
|
||||
// она переживает перезагрузку и умирает вместе с вкладкой.
|
||||
const RELOAD_KEY = "bare-updated-at";
|
||||
const RELOAD_APART = 30 * 1000;
|
||||
|
||||
// Как часто возвращаться к отложенному, пока в полях набранное. Событие
|
||||
// input ловит набор, но опустеть поле умеет и молча: отправленное
|
||||
// сообщение чистит строку присваиванием, а уход с экрана уносит её
|
||||
// из документа вместе с текстом — событий не случается ни там, ни там.
|
||||
// Обход полей раз в полминуты ничего не стоит и заводится только тогда,
|
||||
// когда обновление уже ждёт (ADR-072).
|
||||
const DRAFT_AGAIN = 30 * 1000;
|
||||
|
||||
// Поля, куда набирают текст. Проверяется то, что считается набранным,
|
||||
// а не список исключений: у скрытого поля, переключателя и кнопки
|
||||
// значение непусто по определению, и одно такое поле молча запретило бы
|
||||
// обновление навсегда (ADR-068).
|
||||
const TYPED = new Set(["text", "password", "search", "email", "url", "tel", "number"]);
|
||||
|
||||
// iOS: пуши работают только у приложения, установленного на экран «Домой»
|
||||
// (ADR-011). Признак — docs/ui.md, «Баннер установки»: iPhone|iPad
|
||||
// и navigator.standalone !== true.
|
||||
@@ -40,6 +82,40 @@ const state = {
|
||||
// показывает «запрещены в браузере» (ADR-046). Флаг живёт во вкладке:
|
||||
// перезагрузка пробует снова — причина могла уйти.
|
||||
refused: false,
|
||||
// Обновление (ADR-068). controlled — управляет ли страницей service
|
||||
// worker. У такой страницы смену контроллёра разбирает fresh: другая
|
||||
// версия оболочки стоит перезагрузки, та же — нет (ADR-070). У первого
|
||||
// захода контроллёр появляется и без обновления: его приход сменой
|
||||
// не считается, но запоминается — дальше страница управляемая.
|
||||
controlled: "serviceWorker" in navigator && navigator.serviceWorker.controller !== null,
|
||||
// Новую версию попросили включиться мы сами. Тогда смена контроллёра —
|
||||
// ответ на нашу просьбу, и перезагружаться надо, даже если при загрузке
|
||||
// воркера у страницы ещё не было: в первый заход он ставится тут же,
|
||||
// а обновление может приехать в тот же сеанс. Имя своё: у вопроса про
|
||||
// уведомления рядом живёт свой флаг, и один на двоих означал бы, что
|
||||
// обновление закрывает вопрос про уведомления, а вопрос — обновление.
|
||||
requested: false,
|
||||
// Оболочка сменилась, а перезагрузка отложена набранным текстом.
|
||||
// Просить включения могла и соседняя вкладка с пустыми полями
|
||||
// (ADR-035): контроллёр меняется у всех сразу, а платить за это
|
||||
// черновиком этой вкладки не должен никто. Перезагрузимся, когда
|
||||
// терять станет нечего.
|
||||
changed: false,
|
||||
// Перезагрузка началась: controllerchange приходит один раз, но цена
|
||||
// ошибки — бесконечный цикл.
|
||||
reloading: false,
|
||||
// Новая версия установлена и ждёт включения. Ждать её заставляет
|
||||
// набранный текст: перезагрузка унесла бы его.
|
||||
waiting: null,
|
||||
// Когда последний раз спрашивали сервер об обновлении.
|
||||
checked: 0,
|
||||
// Версия оболочки, код которой исполняет эта страница, — обещание
|
||||
// ответа воркера. С ней сравнивается версия включившейся: совпали —
|
||||
// перезагружаться незачем, оболочка та же (ADR-070).
|
||||
shell: Promise.resolve(null),
|
||||
// Отложенная до конца паузы попытка: другого повода вернуться к ней
|
||||
// может и не быть.
|
||||
timer: 0,
|
||||
};
|
||||
|
||||
// Приглашение установки ловится с первой секунды: браузер показывает его
|
||||
@@ -79,6 +155,266 @@ async function ready() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- обновление ---------------------------------------------------------
|
||||
|
||||
// watch включает самообновление: проверку при запуске и при возвращении
|
||||
// в приложение, включение установленной версии и перезагрузку страницы
|
||||
// (ADR-068). Установленное на «Домой» приложение обновить иначе нечем:
|
||||
// ни строки адреса, ни кнопки перезагрузки в нём нет.
|
||||
export function watch() {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
// Версия оболочки, код которой исполняет эта страница. Спрашивается
|
||||
// сразу: воркер, который её держит, скоро сменится, а сравнивать надо
|
||||
// с тем, чей код уже загружен (ADR-070).
|
||||
state.shell = ask(navigator.serviceWorker.controller);
|
||||
// Контроллёр сменился — работает другая версия оболочки, а страница
|
||||
// исполняет прежнюю. Перезагрузка одна за жизнь страницы.
|
||||
//
|
||||
// Первая установка её не считается: там контроллёр появляется впервые
|
||||
// (clients.claim), а страница и так свежая. Эту смену замок съедает
|
||||
// и запоминает — вместе с версией, которую новый контроллёр держит:
|
||||
// она и есть версия загруженной оболочки.
|
||||
//
|
||||
// Перезагружаемся не отсюда: включить новую версию могла соседняя
|
||||
// вкладка, у которой поля пусты, а у этой в них набранное. Отмечаем
|
||||
// и ждём — apply перезагрузит, когда терять станет нечего.
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
if (state.reloading) {
|
||||
return;
|
||||
}
|
||||
if (!state.requested && !state.controlled) {
|
||||
state.controlled = true;
|
||||
state.shell = ask(navigator.serviceWorker.controller);
|
||||
return;
|
||||
}
|
||||
fresh();
|
||||
});
|
||||
// Возвращение в приложение — единственный момент, когда о нём вспоминают
|
||||
// на телефоне: вкладка не закрывается неделями. Событие приходит
|
||||
// документу, ему и слушаем.
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
check();
|
||||
}
|
||||
});
|
||||
// Набранное стёрли — терять стало нечего, и отложенное обновление
|
||||
// можно включать.
|
||||
addEventListener("input", () => apply(), true);
|
||||
register().then((registration) => {
|
||||
if (registration === null) {
|
||||
return;
|
||||
}
|
||||
registration.addEventListener("updatefound", () => track(registration.installing));
|
||||
// Версия, установившаяся в прошлый заход и не дождавшаяся своей
|
||||
// очереди: вкладку закрыли раньше.
|
||||
track(registration.waiting);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
// check спрашивает сервер, нет ли новой версии. Сети нет — молча:
|
||||
// офлайн не сбой, спросим при следующем возвращении (ADR-068).
|
||||
async function check() {
|
||||
if (Date.now() - state.checked < CHECK_EVERY) {
|
||||
// Спрашивать рано, но отложенное обновление могло дождаться своего
|
||||
// часа: набранное стёрли или отправили.
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
state.checked = Date.now();
|
||||
const registration = await register();
|
||||
if (registration === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await registration.update();
|
||||
} catch {
|
||||
// Сервер молчит или сети нет.
|
||||
}
|
||||
// Ждущая версия берётся и отсюда, а не только из updatefound: событие
|
||||
// случается один раз, а включить её могло не выйти — страница была
|
||||
// занята набранным текстом или сообщение до воркера не дошло.
|
||||
track(registration.waiting);
|
||||
apply();
|
||||
}
|
||||
|
||||
// track следит за устанавливающейся версией: как только она встала
|
||||
// и ждёт, её можно включать.
|
||||
function track(worker) {
|
||||
if (!worker || worker === state.waiting) {
|
||||
return;
|
||||
}
|
||||
const look = () => {
|
||||
if (worker.state === "installed") {
|
||||
state.waiting = worker;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
worker.addEventListener("statechange", look);
|
||||
look();
|
||||
}
|
||||
|
||||
// fresh разбирает смену контроллёра: включилась другая версия оболочки
|
||||
// или тот же файл воркера, отданный сервером заново. Перезагрузка нужна
|
||||
// только в первом случае: во втором она вернула бы ту же самую страницу,
|
||||
// и следующий запрос за sw.js начал бы всё сначала — это и есть петля
|
||||
// (ADR-070).
|
||||
//
|
||||
// Неизвестная версия считается другой: молчит воркер прежнего выпуска,
|
||||
// и обновление до выпуска, который отвечает, важнее. Повторяться этому
|
||||
// не с чего — после перезагрузки отвечают оба.
|
||||
async function fresh() {
|
||||
const [was, now] = await Promise.all([state.shell, ask(navigator.serviceWorker.controller)]);
|
||||
if (state.reloading) {
|
||||
return;
|
||||
}
|
||||
if (was !== null && now !== null && was === now) {
|
||||
return;
|
||||
}
|
||||
state.changed = true;
|
||||
apply();
|
||||
}
|
||||
|
||||
// apply двигает обновление вперёд — если сейчас есть чем платить.
|
||||
// Набранное откладывает и просьбу включиться, и перезагрузку: и то,
|
||||
// и другое кончается новой оболочкой, а она уносит поля (ADR-068).
|
||||
//
|
||||
// Первая установка до просьбы не доходит: страницей ещё никто
|
||||
// не управляет, включать нечего, а воркер и так активируется сам.
|
||||
// Через «installed» он при этом проходит — без проверки контроллёра
|
||||
// первый же запуск перезагружал бы себя сам.
|
||||
function apply() {
|
||||
if (state.reloading) {
|
||||
return;
|
||||
}
|
||||
if (typed()) {
|
||||
// Набранное откладывает обновление, а поводов вернуться к нему мало:
|
||||
// событие input случается при наборе, но не при отправке сообщения
|
||||
// и не при уходе с экрана — там поле пустеет и пропадает молча.
|
||||
// Поэтому ждём ещё и по таймеру (ADR-072).
|
||||
if (state.changed || state.waiting !== null) {
|
||||
later(DRAFT_AGAIN);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.changed) {
|
||||
// Оболочка уже сменилась — просить больше нечего, осталось
|
||||
// перезагрузиться. Слишком часто подряд не перезагружаемся:
|
||||
// пауза кончится, и apply вернётся сюда сам.
|
||||
const wait = pause();
|
||||
if (wait > 0) {
|
||||
later(wait);
|
||||
return;
|
||||
}
|
||||
state.reloading = true;
|
||||
markReload();
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
const worker = state.waiting;
|
||||
if (worker === null || navigator.serviceWorker.controller === null) {
|
||||
return;
|
||||
}
|
||||
state.waiting = null;
|
||||
state.requested = true;
|
||||
worker.postMessage(SKIP);
|
||||
}
|
||||
|
||||
// typed — есть ли на экране набранное. Проверка общая на весь документ,
|
||||
// а не на строку ввода: пароль в форме входа теряется от перезагрузки
|
||||
// так же, как черновик сообщения. Пустое поле под курсором ничего
|
||||
// не стоит: оно и после перезагрузки пустое.
|
||||
//
|
||||
// Набранным считается только то, куда набирают: textarea и поля из
|
||||
// TYPED. Чекбокс, файл, скрытое поле и кнопка непусты сами по себе,
|
||||
// а обновление они бы запретили насовсем.
|
||||
function typed() {
|
||||
for (const node of document.querySelectorAll("input, textarea")) {
|
||||
if (node.value === "") {
|
||||
continue;
|
||||
}
|
||||
if (node.tagName === "TEXTAREA" || TYPED.has(node.type)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ask спрашивает у воркера версию оболочки, которую он держит. Ответ идёт
|
||||
// своим каналом: вопросов бывает два подряд, а перепутать ответы нельзя.
|
||||
//
|
||||
// Молчание — не сбой: так отвечает воркер прежнего выпуска, который такого
|
||||
// вопроса не знает. Тогда версия неизвестна (null), и решение принимается
|
||||
// в пользу обновления (ADR-070).
|
||||
function ask(worker) {
|
||||
return new Promise((done) => {
|
||||
if (!worker) {
|
||||
done(null);
|
||||
return;
|
||||
}
|
||||
const channel = new MessageChannel();
|
||||
const timer = setTimeout(() => {
|
||||
channel.port1.close();
|
||||
done(null);
|
||||
}, ASK_WAIT);
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timer);
|
||||
channel.port1.close();
|
||||
done(typeof event.data === "string" && event.data !== "" ? event.data : null);
|
||||
};
|
||||
try {
|
||||
worker.postMessage(ASK, [channel.port2]);
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
done(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// pause — сколько ещё ждать до перезагрузки ради обновления. Петлю она
|
||||
// не закрывает — это делает сверка версии оболочки (ADR-070); здесь только
|
||||
// предел частоты на случай сервера, который отдаёт разные версии на каждый
|
||||
// запрос. Отметки нет — значит, и повода ждать нет.
|
||||
function pause() {
|
||||
const at = Number(session(RELOAD_KEY));
|
||||
if (!Number.isFinite(at) || at <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const left = RELOAD_APART - (Date.now() - at);
|
||||
return left > 0 ? Math.min(left, RELOAD_APART) : 0;
|
||||
}
|
||||
|
||||
// later возвращается к отложенному сам: другого повода может и не быть —
|
||||
// вкладка открыта, обновление ждёт, а ждать его заставляет то пауза между
|
||||
// перезагрузками (ADR-070), то набранное в полях (ADR-072).
|
||||
function later(ms) {
|
||||
if (state.timer !== 0) {
|
||||
return;
|
||||
}
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = 0;
|
||||
apply();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function markReload() {
|
||||
try {
|
||||
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// Хранилище закрыто политикой браузера: обойдёмся без страховки.
|
||||
}
|
||||
}
|
||||
|
||||
function session(key) {
|
||||
try {
|
||||
return sessionStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- уведомления --------------------------------------------------------
|
||||
|
||||
// supported — есть ли в браузере то, из чего складывается пуш. iOS вне
|
||||
|
||||
+194
-22
@@ -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;
|
||||
|
||||
// Разделители дат: на десктопе — полная дата, на мобильном — короткая,
|
||||
// как в эталоне. Время — ЧЧ:ММ в локальной зоне.
|
||||
@@ -79,7 +84,7 @@ export function renderChat(root, ctx, chatId) {
|
||||
root.append(head(view));
|
||||
|
||||
view.feed = el("div", "feed");
|
||||
view.body = el("div", "grid");
|
||||
view.body = el("div", "feed-list");
|
||||
view.body.setAttribute("aria-live", "polite");
|
||||
view.feed.append(view.body);
|
||||
// Прокрутка к верхнему краю берёт следующую страницу (ADR-053).
|
||||
@@ -90,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) {
|
||||
@@ -113,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));
|
||||
|
||||
@@ -122,7 +144,9 @@ export function renderChat(root, ctx, chatId) {
|
||||
offNet();
|
||||
offPeers();
|
||||
offRooms();
|
||||
offKeyboard();
|
||||
media.removeEventListener("change", onMedia);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,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;
|
||||
}
|
||||
@@ -184,22 +218,35 @@ async function refreshRoom(view, known) {
|
||||
return;
|
||||
}
|
||||
view.name = record?.title || view.roomId;
|
||||
view.title.textContent = titleText(view);
|
||||
view.field.placeholder = `сообщение в #${shortName(view.name)}`;
|
||||
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.
|
||||
view.gone = record?.hidden === true;
|
||||
view.field.disabled = view.gone;
|
||||
view.send.disabled = view.gone;
|
||||
allow(view, !view.gone);
|
||||
paintBar(view);
|
||||
}
|
||||
|
||||
// composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>»
|
||||
// цветом mark. Enter отправляет только на десктопе; на мобильном он делает
|
||||
// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»).
|
||||
//
|
||||
// Строка сообщения — textarea (ADR-076). Проверка на реальном iPhone
|
||||
// показала, что системная панель WebKit бывает и у contenteditable, а
|
||||
// поддерживаемого веб-API для неё нет. Нативное поле зато само держит
|
||||
// обычное редактирование, вставку, предел и доступность.
|
||||
function composer(view) {
|
||||
const form = el("form", "compose");
|
||||
form.noValidate = true;
|
||||
const form = el("div", "compose");
|
||||
form.setAttribute("role", "form");
|
||||
|
||||
view.bar = el("p", "bar");
|
||||
view.bar.hidden = true;
|
||||
@@ -209,21 +256,36 @@ function composer(view) {
|
||||
const prompt = el("span", "p", ">");
|
||||
prompt.setAttribute("aria-hidden", "true");
|
||||
|
||||
view.field = el("textarea", "input__field");
|
||||
view.field = el("textarea", "input__field input__field--text");
|
||||
view.field.rows = 1;
|
||||
view.field.placeholder = "сообщение";
|
||||
hint(view, "сообщение");
|
||||
view.field.maxLength = view.limit;
|
||||
view.field.autocomplete = "off";
|
||||
// Клавиша Enter на мобильном переносит строку, а не отправляет: отправка
|
||||
// там на кнопке «>» (docs/ui.md, «Чат»). Поэтому «enter», а не «send»:
|
||||
// подпись клавиши обещает то, что клавиша делает.
|
||||
view.field.enterKeyHint = "enter";
|
||||
// Сообщение — обычная речь, а не ник и не пароль: заглавная в начале
|
||||
// предложения и исправление опечаток тут к месту (в формах входа
|
||||
// и «нового чата» они, наоборот, выключены).
|
||||
view.field.autocapitalize = "sentences";
|
||||
view.field.setAttribute("autocorrect", "on");
|
||||
|
||||
view.counter = el("span", "counter");
|
||||
view.counter.hidden = true;
|
||||
|
||||
view.send = el("button", "input__send", ">");
|
||||
view.send.type = "submit";
|
||||
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);
|
||||
|
||||
view.field.addEventListener("input", () => count(view));
|
||||
view.field.addEventListener("input", () => {
|
||||
sizeField(view);
|
||||
count(view);
|
||||
});
|
||||
view.field.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.isComposing) {
|
||||
return;
|
||||
@@ -234,14 +296,111 @@ function composer(view) {
|
||||
event.preventDefault();
|
||||
submit(view);
|
||||
});
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
submit(view);
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
// hint держит видимую подсказку и доступное имя поля одинаковыми. Один
|
||||
// placeholder не заменяет подпись для всех экранных дикторов (ADR-076).
|
||||
function hint(view, text) {
|
||||
view.field.placeholder = text;
|
||||
view.field.setAttribute("aria-label", text);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// count — счётчик остатка: появляется после порога (docs/ui.md, «Чат»).
|
||||
function count(view) {
|
||||
const length = view.field.value.length;
|
||||
@@ -255,6 +414,7 @@ function submit(view) {
|
||||
return;
|
||||
}
|
||||
view.field.value = "";
|
||||
sizeField(view);
|
||||
count(view);
|
||||
run(view, () => sync.send(view.chatId, text));
|
||||
}
|
||||
@@ -519,12 +679,25 @@ function line(view, record, previous, parent = view.body) {
|
||||
}
|
||||
// Подряд идущие сообщения одного автора — без повтора автора.
|
||||
const first = marks.length > 0 || !previous || previous.from !== record.from;
|
||||
const node = el("div", first ? "line is-head" : "line");
|
||||
const node = el("div", lineClass(record, view.me, first));
|
||||
node.append(author(view, record, first), text(view, record));
|
||||
parent.append(...marks, node);
|
||||
view.nodes.set(record.id, { node, marks });
|
||||
}
|
||||
|
||||
// lineClass отмечает свои сообщения отдельным классом. На какой стороне он
|
||||
// окажется, CSS решает по основному указателю устройства (ADR-077).
|
||||
function lineClass(record, me, first) {
|
||||
const classes = ["line"];
|
||||
if (record.from === me) {
|
||||
classes.push("line--me");
|
||||
}
|
||||
if (first) {
|
||||
classes.push("is-head");
|
||||
}
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
// redraw обновляет одну строку на месте: автор и группировка от состояния
|
||||
// сообщения не зависят.
|
||||
function redraw(view, record) {
|
||||
@@ -597,9 +770,8 @@ async function checkPeer(view) {
|
||||
return;
|
||||
}
|
||||
view.blocked = !!record?.pending;
|
||||
// Ввод заблокирован целиком: и поле, и кнопка «>» на мобильном.
|
||||
view.field.disabled = view.blocked;
|
||||
view.send.disabled = view.blocked;
|
||||
// Ввод заблокирован целиком: и блок, и кнопка «>» на мобильном.
|
||||
allow(view, !view.blocked);
|
||||
paintBar(view);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
+30
-2
@@ -5,6 +5,12 @@ import * as pwa from "../pwa.js";
|
||||
import { INSTALL_IOS, clear, el, mark } from "./dom.js";
|
||||
import { mount } from "./chats.js";
|
||||
|
||||
// Время коммита — в местной зоне и коротко: день с месяцем и часы
|
||||
// с минутами (ADR-067). Год не показывается: версия отвечает на вопрос
|
||||
// «что сейчас работает», а не ведёт летопись.
|
||||
const BUILD_DAY = new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||
const BUILD_TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
// frame отдаёт корень, место под экран и отписку списка чатов.
|
||||
// screen — что показывать на мобильном, где виден один экран за раз:
|
||||
// "list" или "screen". active — чат, который сейчас открыт.
|
||||
@@ -38,17 +44,39 @@ function side(ctx, active) {
|
||||
list.append(add, items);
|
||||
nav.append(list);
|
||||
|
||||
const foot = el("div", "foot");
|
||||
const me = el("button", "me");
|
||||
me.type = "button";
|
||||
const dot = el("i");
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
me.append(dot, el("span", null, `ты: @${ctx.me.nick}`));
|
||||
me.append(dot, el("span", "me__nick", `ты: @${ctx.me.nick}`));
|
||||
me.addEventListener("click", () => ctx.go("#/settings"));
|
||||
nav.append(me);
|
||||
foot.append(me);
|
||||
const version = build(ctx.config);
|
||||
if (version !== null) {
|
||||
foot.append(el("span", "build", version));
|
||||
}
|
||||
nav.append(foot);
|
||||
|
||||
return { nav, dispose: mount(items, ctx, active) };
|
||||
}
|
||||
|
||||
// build — версия и время коммита из GET /api/config (ADR-074, ADR-067).
|
||||
// Конфигурации нет — офлайн-старт до первого ответа сервера — значит,
|
||||
// и строки нет: выдумывать версию не из чего. Время без версии не бывает:
|
||||
// её сервер отдаёт всегда, хотя бы как «unknown».
|
||||
function build(config) {
|
||||
const version = config?.version;
|
||||
if (typeof version !== "string" || version === "") {
|
||||
return null;
|
||||
}
|
||||
const at = config?.commitAt;
|
||||
if (!Number.isFinite(at) || at <= 0) {
|
||||
return version;
|
||||
}
|
||||
return `${version} · ${BUILD_DAY.format(at)} ${BUILD_TIME.format(at)}`;
|
||||
}
|
||||
|
||||
// banner — баннер установки на iOS: пуши там работают только
|
||||
// у установленного приложения (ADR-011). Крестик закрывает его насовсем.
|
||||
async function banner(place) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Запрет масштабирования — страховка к строке viewport (ADR-075).
|
||||
//
|
||||
// Основное средство — `maximum-scale=1, user-scalable=no` в index.html
|
||||
// и `touch-action: manipulation` в app.css. Safari вправе не послушаться:
|
||||
// он и раньше игнорировал `user-scalable=no` целиком. Здесь то, что
|
||||
// работает независимо от его настроения, — и ничего больше: прокрутка,
|
||||
// свайпы и обычные нажатия проходят как проходили.
|
||||
|
||||
// Щипок в Safari — это gesturestart/gesturechange/gestureend. В других
|
||||
// браузерах их не бывает вовсе, и подписка ничего не стоит.
|
||||
const GESTURES = ["gesturestart", "gesturechange", "gestureend"];
|
||||
|
||||
// Два тапа подряд ближе этого срока и этого расстояния друг к другу —
|
||||
// двойной тап, то есть масштабирование. Пороги браузерные: свои цифры
|
||||
// здесь всё равно ничем не проверить.
|
||||
const TAP_APART = 350;
|
||||
const TAP_NEAR = 40;
|
||||
|
||||
// Прошлый тап. До первого касания его не было вовсе: иначе тап в углу
|
||||
// в первые триста миллисекунд жизни страницы сошёл бы за второй.
|
||||
const last = { at: -Infinity, x: 0, y: 0 };
|
||||
|
||||
// lock вешает страховку. Слушатели непассивные: пассивному
|
||||
// preventDefault не даёт ничего.
|
||||
export function lock() {
|
||||
for (const name of GESTURES) {
|
||||
document.addEventListener(name, stop, { passive: false });
|
||||
}
|
||||
document.addEventListener("touchend", tap, { passive: false });
|
||||
}
|
||||
|
||||
function stop(event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// tap гасит второй тап подряд — он и масштабирует. Первый доходит
|
||||
// до страницы целиком, поэтому нажатия, свайпы и прокрутка не меняются.
|
||||
function tap(event) {
|
||||
// Второй палец на экране — это щипок или прокрутка двумя, не тап.
|
||||
if (event.touches.length > 0 || event.changedTouches.length !== 1) {
|
||||
last.at = -Infinity;
|
||||
return;
|
||||
}
|
||||
const touch = event.changedTouches[0];
|
||||
const quick = event.timeStamp - last.at <= TAP_APART;
|
||||
const near = Math.abs(touch.clientX - last.x) <= TAP_NEAR
|
||||
&& Math.abs(touch.clientY - last.y) <= TAP_NEAR;
|
||||
last.at = event.timeStamp;
|
||||
last.x = touch.clientX;
|
||||
last.y = touch.clientY;
|
||||
if (!quick || !near || control(event.target)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// control — элемент управления. Второй тап по нему не гасится:
|
||||
// preventDefault на touchend уносит с собой click, а нажать кнопку
|
||||
// дважды подряд — обычное дело. Цена мала: до элементов управления зум
|
||||
// по двойному тапу не доходит и без страховки — `touch-action:
|
||||
// manipulation` стоит на документе.
|
||||
function control(target) {
|
||||
return typeof target?.closest === "function"
|
||||
&& target.closest("button, input, textarea, select, label, a") !== null;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
|
||||
// и старые кэши уходят в activate.
|
||||
|
||||
const VERSION = "v4";
|
||||
const VERSION = "v8";
|
||||
const CACHE = `bare-${VERSION}`;
|
||||
|
||||
// Оболочка — всё, из чего клиент поднимается без сети. Список явный:
|
||||
@@ -25,6 +25,7 @@ const SHELL = [
|
||||
"/js/pwa.js",
|
||||
"/js/sync.js",
|
||||
"/js/ulid.js",
|
||||
"/js/zoom.js",
|
||||
"/js/ui/auth.js",
|
||||
"/js/ui/chat.js",
|
||||
"/js/ui/chats.js",
|
||||
@@ -54,6 +55,25 @@ self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
|
||||
});
|
||||
|
||||
// Установленная версия ждёт очереди, пока страница не попросит её включить
|
||||
// (ADR-068). Просит именно страница: в установленном на «Домой»
|
||||
// приложении перезагрузить оболочку больше нечем, а терять набранное
|
||||
// в строке ввода нельзя — знает об этом только она.
|
||||
//
|
||||
// На «version» воркер называет свою версию. По ней страница отличает новую
|
||||
// оболочку от того же файла воркера, отданного сервером заново: первая
|
||||
// стоит перезагрузки, второй — нет (ADR-070). Ответ уходит каналом
|
||||
// вопроса: вопросов бывает два подряд, к разным воркерам.
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data === "skip-waiting") {
|
||||
self.skipWaiting();
|
||||
return;
|
||||
}
|
||||
if (event.data === "version") {
|
||||
event.ports[0]?.postMessage(VERSION);
|
||||
}
|
||||
});
|
||||
|
||||
// activate уносит кэши прежних версий: имя кэша содержит версию, и всё,
|
||||
// что названо иначе, — прошлый релиз. clients.claim берёт под контроль
|
||||
// уже открытую страницу: без этого первый запуск остался бы без кэша
|
||||
|
||||
Reference in New Issue
Block a user