Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec1affd35 | ||
|
|
73f95d5e7e | ||
|
|
6e31889f11 | ||
|
|
d0a7fab09c | ||
|
|
3a30cc63d3 | ||
|
|
a5955030bb |
@@ -1,28 +1,75 @@
|
||||
# Bare
|
||||
<p align="center">
|
||||
<img src="docs/identity/hero.png" alt="" width="100%">
|
||||
</p>
|
||||
|
||||
Максимально простой веб-чат. Web-native PWA на ванильных технологиях: HTML, CSS, vanilla JS — и Go-сервер одним бинарём. Ноль npm-зависимостей, никакой сборки.
|
||||
<h1 align="center">bare</h1>
|
||||
|
||||
Bare — маленький независимый инструмент, а не конкурент Discord, Slack или Telegram. Название буквальное: голый, без лишнего, ничего не спрятано за слоями абстракций.
|
||||
<p align="center">
|
||||
сообщения шифруются в браузере · история остаётся на устройстве · сервер доставляет шифротекст
|
||||
</p>
|
||||
|
||||
Сервер — реле, а не архив: он передаёт зашифрованные сообщения и забывает их. История живёт только на устройстве. E2EE по умолчанию — оператор видит только шифротекст. Забыл пароль — значит забыл: восстановления нет, потому что пароль — материал ключа.
|
||||
<p align="center">
|
||||
<a href="https://bare.xmatic.team">открыть bare</a> ·
|
||||
<a href="docs/philosophy.md">философия</a> ·
|
||||
<a href="docs/threat-model.md">модель угроз</a>
|
||||
</p>
|
||||
|
||||
## Документация
|
||||
## что это
|
||||
|
||||
- [Философия](docs/philosophy.md) — манифест
|
||||
- [Архитектура](docs/architecture.md) — обзор системы
|
||||
- [Модель угроз](docs/threat-model.md) — от чего защищаемся и от чего нет
|
||||
- [Решения](docs/decisions/) — ADR по ключевым решениям
|
||||
- [Криптография](docs/crypto.md), [протокол](docs/protocol.md), [хранение](docs/storage.md) — спецификации для кода
|
||||
- [Интерфейс](docs/ui.md) и [айдентика](docs/identity/brief.md)
|
||||
- [Деплой](docs/deploy.md) и [план реализации](docs/plan.md)
|
||||
- [Открытые вопросы](docs/open-questions.md)
|
||||
Bare — маленький независимый E2EE-чат, а не платформа. Он умеет личные чаты и комнаты, работает как PWA, присылает уведомления и переносит локальную историю через зашифрованный файл `.bare`.
|
||||
|
||||
## Статус
|
||||
Клиент — HTML, CSS и vanilla JS без npm и сборки. Сервер — Go + SQLite, статика встроена в один бинарь.
|
||||
|
||||
Все шесть этапов `docs/plan.md` сделаны и работают на [bare.xmatic.team](https://bare.xmatic.team): аккаунты, чат 1:1, комнаты с ключом на комнату, TOFU и отпечатки, PWA и пуши, экспорт и импорт истории, лимиты и закалка.
|
||||
- **без аккаунтной обвязки** — только ник и пароль, без email, телефона и OAuth;
|
||||
- **локальная история** — сообщения после доставки живут в IndexedDB текущего устройства, а не в облачном архиве;
|
||||
- **сервер-реле** — сервер хранит метаданные и временную очередь недоставленных шифротекстов, удаляя сообщения после подтверждения устройства;
|
||||
- **криптография в браузере** — пароль не покидает клиент, личные чаты и комнаты шифруются через WebCrypto;
|
||||
- **простая поверхность аудита** — клиент состоит из читаемых ES-модулей без бандлера и скрытого шага сборки.
|
||||
|
||||
Осталось ручное: прогон сценариев на iOS Safari (установленное на «Домой» приложение), Android Chrome, десктопных Firefox и Safari. Автоматика гоняла только Chrome. Чеклист — в `docs/plan.md`, этап 4.
|
||||
## внутри
|
||||
|
||||
## Лицензия
|
||||
| слой | решение |
|
||||
|---|---|
|
||||
| клиент | HTML, CSS, vanilla JS, PWA, IndexedDB |
|
||||
| сервер | Go, SQLite, HTTPS, SSE |
|
||||
| ключи | ECDH P-256, PBKDF2, HKDF, AES-GCM |
|
||||
| доверие | TOFU и сверка отпечатков при смене ключа |
|
||||
| история | только на устройстве; ручной экспорт и импорт `.bare` |
|
||||
|
||||
## запустить локально
|
||||
|
||||
Нужен Go 1.27.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/xmatic-squad/bare.git
|
||||
cd bare
|
||||
go run ./cmd/bare serve
|
||||
```
|
||||
|
||||
Откройте [http://127.0.0.1:8411](http://127.0.0.1:8411). Без переменных окружения Bare создаст `bare.db` в текущей папке, разрешит регистрацию и запустится без пушей.
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Production-настройка, VAPID, nginx и systemd описаны в [руководстве по развёртыванию](docs/deploy.md).
|
||||
|
||||
## честные ограничения
|
||||
|
||||
- Забыл пароль — значит забыл: восстановления нет, потому что пароль участвует в выводе ключей.
|
||||
- История в IndexedDB не зашифрована at rest и доступна коду, работающему на устройстве.
|
||||
- E2EE не скрывает метаданные, не защищает скомпрометированное устройство и не спасает от оператора, который подменил веб-клиент.
|
||||
- Forward secrecy в v1 нет. На iOS пуши работают только у PWA, установленного на экран «Домой».
|
||||
|
||||
Полная граница обещаний и допущений — в [модели угроз](docs/threat-model.md).
|
||||
|
||||
## документация
|
||||
|
||||
- [Философия](docs/philosophy.md), [архитектура](docs/architecture.md), [модель угроз](docs/threat-model.md)
|
||||
- [Криптография](docs/crypto.md), [HTTP-протокол](docs/protocol.md), [хранение](docs/storage.md)
|
||||
- [Интерфейс](docs/ui.md), [айдентика](docs/identity/brief.md), [архитектурные решения](docs/decisions/)
|
||||
- [Деплой](docs/deploy.md), [план v1](docs/plan.md), [открытые вопросы](docs/open-questions.md)
|
||||
|
||||
## лицензия
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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) (стороны сообщений зависят от способа ввода).
|
||||
Уточнён [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) (стороны сообщений зависят от способа ввода) и [ADR-079](079-profile-glyph-and-settings-layout.md) (фиксированная пиктограмма профиля и компоновка настроек).
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ADR-067: Версия и время коммита в подвале сайдбара
|
||||
|
||||
Уточнён [ADR-079](079-profile-glyph-and-settings-layout.md): кнопка «ты: @nick» стала локальной пиктограммой профиля с `@nick`; версия осталась отдельной строкой рядом.
|
||||
|
||||
Доводит до интерфейса [ADR-074](074-version-and-commit-time.md): сервер отдаёт `version` и `commitAt`, а где их показывать, там не решено.
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-078: «Новый чат» — выбор действия
|
||||
|
||||
## Контекст
|
||||
|
||||
На экране `#/new` одновременно стояли две одинаковые строки: `@ник` и `#имя комнаты`. Это две независимые формы, но экран не говорил «или», а на десктопе их кнопки `>` скрывал общий стиль строки чата. Человек мог заполнить обе строки и ожидать, что ник станет участником комнаты. Enter при этом отправлял только форму поля в фокусе: свой ник давал «нельзя писать себе», а название комнаты создавало комнату только с владельцем.
|
||||
|
||||
Название комнаты не является адресом. Оно не уникально, сервер не даёт поиск комнат и возвращает пользователю только комнаты, где он уже состоит. Из-за сквозного шифрования новый участник получает доступ после того, как владелец добавит его по нику и раздаст новый ключ (ADR-018). Самостоятельно войти по одному открытому названию нельзя.
|
||||
|
||||
## Решение
|
||||
|
||||
- `#/new` показывает переключатель «личный чат / новая комната» и только одну форму за раз. У каждой формы есть видимая подпись, текстовая кнопка и своя строка ошибки. Начальное поле не фокусируется автоматически.
|
||||
- Сценарий комнаты прямо называет её новой и приватной: сначала в ней только создатель; если владельцу другой комнаты нужен этот пользователь, владелец добавляет его, после чего комната сама появляется в списке.
|
||||
- После создания открывается `#/room/<id>/members`, а не пустая лента. Создатель сразу видит себя владельцем и форму добавления людей.
|
||||
- В шапке комнаты рядом с именем стоит подпись `· участники N` внутри той же кнопки. Число — размер состава, не присутствие онлайн; кнопка ведёт на экран участников.
|
||||
|
||||
## Последствия
|
||||
|
||||
Заполнить личный чат и комнату как одну составную форму больше нельзя. Кнопки действий видны и на десктопе, и на мобильном; Enter относится только к показанной форме.
|
||||
|
||||
Вход по имени, публичный каталог и ссылки-приглашения не добавляются. Такой сценарий потребует отдельного адреса или уникальности, правил одобрения и раздачи ключа; это не изменение текста формы.
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-079: Пиктограмма профиля и компоновка настроек
|
||||
|
||||
Уточняет [ADR-024](024-identity-and-ui.md): аватаров по-прежнему нет, но переходу в настройки нужна однозначная пиктограмма. Уточняет [ADR-067](067-version-in-sidebar-foot.md): состав кнопки профиля меняется, версия остаётся рядом с ней.
|
||||
|
||||
## Контекст
|
||||
|
||||
В подвале сайдбара переход в настройки обозначен точкой и текстом «ты: @nick». Точка не объясняет действие, а слово «ты» занимает место у ника. Использовать эмодзи нельзя: его вид зависит от платформы. Подключать библиотеку ради одной пиктограммы противоречит устройству клиента.
|
||||
|
||||
Экран настроек ограничен одной колонкой 460 px и на десктопе остаётся длинной узкой лентой. Формы внутри неё дополнительно ограничены 360 px, поэтому поля смены пароля и удаления аккаунта короче соседних кнопок.
|
||||
|
||||
## Решение
|
||||
|
||||
- Кнопка настроек в подвале состоит из локального `profile.svg` и `@nick`. Её доступное имя — «настройки: @nick».
|
||||
- Пиктограмма фиксированная, не пользовательская: это указатель на профиль, а не аватар. Круглая голова делает назначение узнаваемым, угловатые открытые плечи, viewBox 64, один штрих 7 px и цвет `ink` связывают её со «Скобами». Человека внутрь фирменного знака не помещаем.
|
||||
- Ассет хранится в клиенте и входит в офлайн-оболочку. Зависимостей и сборки не появляется.
|
||||
- До ширины 959 px настройки идут одной колонкой в прежнем порядке. От 960 px блок идентичности занимает всю ширину, ниже стоят две равные колонки с промежутком 32 px: уведомления, установка, устройства и история слева; смена пароля, выход и удаление аккаунта справа.
|
||||
- Ширина двух колонок вместе — не больше 752 px. Поля и обычные кнопки занимают полную ширину своей колонки и сохраняют общую минимальную высоту 44 px.
|
||||
- Новая компоновка относится только к `#/settings`. Карточка контакта и участники, использующие тот же базовый класс узкого содержимого, не меняются.
|
||||
|
||||
## Следствия
|
||||
|
||||
- В подвале остаются только пиктограмма, ник и независимая строка версии; длинный ник по-прежнему обрезается многоточием.
|
||||
- На телефоне порядок и одноколоночная компоновка настроек сохраняются. На широком десктопе путь чтения делится на повседневные настройки слева и действия с доступом к аккаунту справа.
|
||||
- Смена `app.css`, модулей и состава оболочки требует новой версии service worker.
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
Wordmark — слово `bare` строчными рядом со знаком, тем же шрифтом, что интерфейс. «Bare» с заглавной — только в тексте.
|
||||
|
||||
Пиктограмма профиля — не вариант знака и не аватар. Это отдельный фиксированный `profile.svg`: круглая голова и открытые угловатые плечи, тот же viewBox 64, один цвет `ink` и штрих 7. Внутрь фирменного знака человека не помещать (ADR-079).
|
||||
|
||||
## Цвет
|
||||
|
||||
| имя | значение | роль |
|
||||
@@ -42,6 +44,7 @@ font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monos
|
||||
## Компоновка
|
||||
|
||||
- Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px.
|
||||
- Настройки: одна колонка до 959 px; от 960 px идентичность во всю ширину и две равные колонки шириной до 360 px с промежутком 32 px. Поля и кнопки внутри колонок одной ширины (ADR-079).
|
||||
- Мобильный: шапка 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 слева.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 852 KiB |
@@ -5,4 +5,4 @@
|
||||
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
|
||||
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
|
||||
|
||||
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 и ADR-069 — попытки убрать системную панель ввода iOS, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец», ADR-067…075 — версия в подвале, самообновление PWA, строка ввода, запрет масштабирования, код причины отказа пушей. ADR-076 фиксирует системное ограничение панели iOS и возврат к `textarea`, ADR-077 — стороны сообщений отдельно для touch и десктопа.
|
||||
Закрыто 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 — выбор между личным чатом и новой комнатой, ADR-079 — пиктограмма профиля и двухколоночные настройки.
|
||||
|
||||
+11
-5
@@ -24,19 +24,23 @@
|
||||
|
||||
## Список чатов (сайдбар)
|
||||
|
||||
Секции «каналы» и «личные», как в моке. Активный чат — инверсия (ink на bone). Непрочитанные — число цветом `mark` справа. Порядок — по `lastId` по убыванию. Внизу — «ты: @nick», по нажатию — настройки. Над секциями — строка `+ новый чат`.
|
||||
Секции «каналы» и «личные», как в моке. Активный чат — инверсия (ink на bone). Непрочитанные — число цветом `mark` справа. Порядок — по `lastId` по убыванию. Внизу — локальная пиктограмма профиля и `@nick`, по нажатию — настройки. Пиктограмма фиксированная и не является аватаром (ADR-079). Над секциями — строка `+ новый чат`.
|
||||
|
||||
В подвале рядом с «ты: @nick» — версия и время коммита из `GET /api/config`: `версия · дд.мм чч:мм`, время в местной зоне, цветом `stone`, размером подписи. Не кнопка, но выделяется: её копируют. Времени нет — остаётся версия; конфигурации ещё нет — строки нет. В сайдбаре 224 px рядом они не помещаются: версия занимает вторую строку и стоит справа; на мобильном, где сайдбар во всю ширину, обе стоят в одной строке. Ник, которому не хватило места, обрезается многоточием (ADR-067).
|
||||
В подвале рядом с кнопкой профиля — версия и время коммита из `GET /api/config`: `версия · дд.мм чч:мм`, время в местной зоне, цветом `stone`, размером подписи. Не кнопка, но выделяется: её копируют. Времени нет — остаётся версия; конфигурации ещё нет — строки нет. В сайдбаре 224 px рядом они не помещаются: версия занимает вторую строку и стоит справа; на мобильном, где сайдбар во всю ширину, обе стоят в одной строке. Ник, которому не хватило места, обрезается многоточием (ADR-067, ADR-079).
|
||||
|
||||
Версия — серверная. Оболочка в браузере бывает старше: она приезжает из кэша, а обновление применяется отдельно и ждёт пустых полей (ADR-068).
|
||||
|
||||
## Новый чат (`#/new`)
|
||||
|
||||
Две строки ввода: `@ник` → открыть личный чат; `#имя комнаты` → создать комнату. Ошибки: «такого ника нет», «нельзя писать себе».
|
||||
Один экран с переключателем двух взаимоисключающих действий: «личный чат / новая комната». Видна только форма выбранного действия; начальный режим — личный чат, но поле само не получает фокус.
|
||||
|
||||
«Личный чат»: текст «напишите человеку по нику.», поле «ник человека» с подсказкой `@ник`, кнопка «написать». Ошибки: «такого ника нет», «нельзя писать себе».
|
||||
|
||||
«Новая комната»: текст «создайте новую приватную комнату. сначала в ней будете только вы; участников добавите следующим шагом.» и пояснение «если вас добавят в чужую комнату, она появится в списке сама.». Поле «название новой комнаты» с подсказкой `#название`, кнопка «создать комнату». Имя не ищет и не открывает существующую комнату: оно не уникально, а доступ даёт владелец добавлением по нику. После создания открывается экран участников новой комнаты (ADR-078).
|
||||
|
||||
## Чат
|
||||
|
||||
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн».
|
||||
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. У комнаты в той же кнопке стоит подпись «· участники N»; это число состава, не присутствующих онлайн. Без темы и «N онлайн».
|
||||
|
||||
Лента: поток блоков сверху вниз; подряд идущие сообщения одного автора — без повтора автора над группой. На телефоне и планшете свои сообщения выровнены по правому краю, чужие — по левому; на десктопе все сообщения стоят слева (ADR-077). Десктоп определяется точным указателем с наведением, а не шириной окна: широкий планшет остаётся планшетом. Свой ник остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока всегда выровнен по левому краю. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
|
||||
|
||||
@@ -62,10 +66,12 @@
|
||||
|
||||
## Участники (`#/room/<id>/members`)
|
||||
|
||||
Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
|
||||
Заголовок «участники» и список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
|
||||
|
||||
## Настройки (`#/settings`)
|
||||
|
||||
До 959 px разделы идут одной колонкой. От 960 px блок с собственной идентичностью занимает всю ширину, а остальные разделы делятся на две равные колонки: уведомления, установка, устройства и история слева; смена пароля, выход и удаление аккаунта справа. Поля и обычные кнопки занимают полную ширину колонки и имеют одинаковую минимальную высоту 44 px (ADR-079).
|
||||
|
||||
- «ты: @nick», свой отпечаток.
|
||||
- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка «включить» или «выключить». `запрещены в браузере` — разрешение отклонено или уведомлений в браузере нет вовсе; кнопки в этом состоянии нет (ADR-046). На iOS вне PWA — состояние `выключены` и вместо кнопки текст про установку, тот же, что в баннере.
|
||||
- «установить приложение»: кнопка «установить», если есть `beforeinstallprompt`; на iOS вне PWA — инструкция «поделиться → на экран «домой»». Устанавливать нечего — раздела нет.
|
||||
|
||||
+96
-9
@@ -83,7 +83,7 @@ button {
|
||||
}
|
||||
|
||||
/* #app — колонка ровно в высоту окна: от неё считают высоту экраны,
|
||||
поэтому сайдбар с «ты: @nick» стоит на месте, а прокручивается
|
||||
поэтому профиль в подвале сайдбара стоит на месте, а прокручивается
|
||||
только содержимое */
|
||||
|
||||
#app {
|
||||
@@ -306,6 +306,36 @@ input[type="password"] {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* новый чат — одно действие за раз (ADR-078). Подсказки стоят до формы,
|
||||
чтобы смысл выбора был понятен до фокуса в поле */
|
||||
|
||||
.new {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.new__tabs {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.new__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.new__copy {
|
||||
max-width: 420px;
|
||||
margin: 0 0 18px;
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.new__aside {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--mute);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* каркас: сайдбар и экран */
|
||||
|
||||
.shell {
|
||||
@@ -407,7 +437,8 @@ input[type="password"] {
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
/* подвал сайдбара: «ты: @nick» и версия с временем коммита (ADR-067).
|
||||
/* подвал сайдбара: пиктограмма профиля, @nick и версия с временем коммита
|
||||
(ADR-067, ADR-079).
|
||||
Версия не кнопка: нажимать в ней нечего, поэтому она стоит рядом
|
||||
с кнопкой, а не внутри неё. В сайдбаре 224 px они рядом не помещаются
|
||||
никогда — версия занимает вторую строку и остаётся справа; на мобильном,
|
||||
@@ -443,11 +474,11 @@ input[type="password"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.me i {
|
||||
.me__icon {
|
||||
display: block;
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--ink);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ник бывает в 32 символа (ADR-019): он обрезается, а версия остаётся
|
||||
@@ -531,6 +562,17 @@ input[type="password"] {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
/* На экране настроек все поля и действия занимают одну ширину. Глобальный
|
||||
предел .form остаётся у входа и остальных коротких форм. */
|
||||
|
||||
.settings--account .form {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.settings__column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.block {
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
@@ -717,6 +759,9 @@ input[type="password"] {
|
||||
/* чат: шапка, лента, ввод — docs/identity/screens.html */
|
||||
|
||||
.chat-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -728,6 +773,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 {
|
||||
@@ -1055,18 +1113,47 @@ input[type="password"] {
|
||||
--field-pad: 13px;
|
||||
}
|
||||
|
||||
/* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в
|
||||
строке `@ник`: в рамке ввода «>» остаётся один, слева
|
||||
(docs/identity/brief.md, «Компоновка») */
|
||||
/* на десктопе чат отправляет enter — кнопка `>` не нужна: в рамке
|
||||
ввода она остаётся одна, слева (docs/identity/brief.md, «Компоновка»).
|
||||
Остальные формы используют обычные текстовые `.button` */
|
||||
|
||||
.input__send {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* На широком десктопе настройки используют место справа от сайдбара:
|
||||
повседневные действия и действия аккаунта идут в двух равных колонках. */
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.settings--account {
|
||||
/* 752 px содержимого: две колонки по 360 px и промежуток 32 px;
|
||||
ещё 64 px занимает desktop-padding самой .body */
|
||||
max-width: 816px;
|
||||
}
|
||||
|
||||
.settings__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* мобильный: один экран за раз, цели нажатия не меньше 44 px */
|
||||
|
||||
@media (max-width: 759px) {
|
||||
/* На мобильном версия и профиль остаются в одной строке; место под
|
||||
фиксированную версию отдаёт ник, который умеет обрезаться. */
|
||||
|
||||
.foot {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.me {
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.shell[data-screen="list"] .main {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="#1B1917" stroke-width="7" stroke-linecap="square" stroke-linejoin="miter"><circle cx="32" cy="19" r="9"/><path d="M10 54v-8l14-10h16l14 10v8"/></svg>
|
||||
|
After Width: | Height: | Size: 230 B |
+21
-2
@@ -167,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;
|
||||
}
|
||||
@@ -208,7 +218,16 @@ async function refreshRoom(view, known) {
|
||||
return;
|
||||
}
|
||||
view.name = record?.title || view.roomId;
|
||||
view.title.textContent = titleText(view);
|
||||
const title = titleText(view);
|
||||
view.titleName.textContent = title;
|
||||
const count = record?.hidden === true || !Array.isArray(record?.members)
|
||||
? null
|
||||
: record.members.length;
|
||||
view.titleMembers.textContent = count === null ? "· участники" : `· участники ${count}`;
|
||||
view.title.setAttribute(
|
||||
"aria-label",
|
||||
count === null ? `${title}, открыть участников` : `${title}, открыть участников: ${count}`,
|
||||
);
|
||||
hint(view, `сообщение в #${shortName(view.name)}`);
|
||||
// Скрытая запись комнаты — это room_left или собственный выход:
|
||||
// отправлять больше некуда, и сервер ответил бы not_member.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+12
-4
@@ -50,13 +50,21 @@ const IMPORT_FAILED = "импорт не удался";
|
||||
|
||||
export function renderSettings(root, ctx) {
|
||||
root.append(head(ctx));
|
||||
const body = el("div", "body settings");
|
||||
body.append(identity(ctx), notificationsBlock(ctx));
|
||||
const body = el("div", "body settings settings--account");
|
||||
const general = el("div", "settings__column");
|
||||
general.append(notificationsBlock(ctx));
|
||||
const install = installBlock();
|
||||
if (install !== null) {
|
||||
body.append(install);
|
||||
general.append(install);
|
||||
}
|
||||
body.append(devicesBlock(ctx), historyBlock(), passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx));
|
||||
general.append(devicesBlock(ctx), historyBlock());
|
||||
|
||||
const security = el("div", "settings__column");
|
||||
security.append(passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx));
|
||||
|
||||
const grid = el("div", "settings__grid");
|
||||
grid.append(general, security);
|
||||
body.append(identity(ctx), grid);
|
||||
root.append(body);
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -47,9 +47,12 @@ function side(ctx, active) {
|
||||
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", "me__nick", `ты: @${ctx.me.nick}`));
|
||||
me.setAttribute("aria-label", `настройки: @${ctx.me.nick}`);
|
||||
const profile = el("img", "me__icon");
|
||||
profile.src = "/icons/profile.svg";
|
||||
profile.alt = "";
|
||||
profile.draggable = false;
|
||||
me.append(profile, el("span", "me__nick", `@${ctx.me.nick}`));
|
||||
me.addEventListener("click", () => ctx.go("#/settings"));
|
||||
foot.append(me);
|
||||
const version = build(ctx.config);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
|
||||
// и старые кэши уходят в activate.
|
||||
|
||||
const VERSION = "v7";
|
||||
const VERSION = "v9";
|
||||
const CACHE = `bare-${VERSION}`;
|
||||
|
||||
// Оболочка — всё, из чего клиент поднимается без сети. Список явный:
|
||||
@@ -37,6 +37,7 @@ const SHELL = [
|
||||
"/js/ui/shell.js",
|
||||
"/icons/icon.svg",
|
||||
"/icons/mark.svg",
|
||||
"/icons/profile.svg",
|
||||
"/icons/icon-180.png",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
|
||||
Reference in New Issue
Block a user