diff --git a/docs/decisions/078-new-chat-is-a-choice.md b/docs/decisions/078-new-chat-is-a-choice.md new file mode 100644 index 0000000..f5ea812 --- /dev/null +++ b/docs/decisions/078-new-chat-is-a-choice.md @@ -0,0 +1,20 @@ +# ADR-078: «Новый чат» — выбор действия + +## Контекст + +На экране `#/new` одновременно стояли две одинаковые строки: `@ник` и `#имя комнаты`. Это две независимые формы, но экран не говорил «или», а на десктопе их кнопки `>` скрывал общий стиль строки чата. Человек мог заполнить обе строки и ожидать, что ник станет участником комнаты. Enter при этом отправлял только форму поля в фокусе: свой ник давал «нельзя писать себе», а название комнаты создавало комнату только с владельцем. + +Название комнаты не является адресом. Оно не уникально, сервер не даёт поиск комнат и возвращает пользователю только комнаты, где он уже состоит. Из-за сквозного шифрования новый участник получает доступ после того, как владелец добавит его по нику и раздаст новый ключ (ADR-018). Самостоятельно войти по одному открытому названию нельзя. + +## Решение + +- `#/new` показывает переключатель «личный чат / новая комната» и только одну форму за раз. У каждой формы есть видимая подпись, текстовая кнопка и своя строка ошибки. Начальное поле не фокусируется автоматически. +- Сценарий комнаты прямо называет её новой и приватной: сначала в ней только создатель; если владельцу другой комнаты нужен этот пользователь, владелец добавляет его, после чего комната сама появляется в списке. +- После создания открывается `#/room//members`, а не пустая лента. Создатель сразу видит себя владельцем и форму добавления людей. +- В шапке комнаты рядом с именем стоит подпись `· участники N` внутри той же кнопки. Число — размер состава, не присутствие онлайн; кнопка ведёт на экран участников. + +## Последствия + +Заполнить личный чат и комнату как одну составную форму больше нельзя. Кнопки действий видны и на десктопе, и на мобильном; Enter относится только к показанной форме. + +Вход по имени, публичный каталог и ссылки-приглашения не добавляются. Такой сценарий потребует отдельного адреса или уникальности, правил одобрения и раздачи ключа; это не изменение текста формы. diff --git a/docs/open-questions.md b/docs/open-questions.md index 9e5b453..4923d7a 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -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 — выбор между личным чатом и новой комнатой. diff --git a/docs/ui.md b/docs/ui.md index 5170cbf..0284308 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -32,11 +32,15 @@ ## Новый чат (`#/new`) -Две строки ввода: `@ник` → открыть личный чат; `#имя комнаты` → создать комнату. Ошибки: «такого ника нет», «нельзя писать себе». +Один экран с переключателем двух взаимоисключающих действий: «личный чат / новая комната». Видна только форма выбранного действия; начальный режим — личный чат, но поле само не получает фокус. + +«Личный чат»: текст «напишите человеку по нику.», поле «ник человека» с подсказкой `@ник`, кнопка «написать». Ошибки: «такого ника нет», «нельзя писать себе». + +«Новая комната»: текст «создайте новую приватную комнату. сначала в ней будете только вы; участников добавите следующим шагом.» и пояснение «если вас добавят в чужую комнату, она появится в списке сама.». Поле «название новой комнаты» с подсказкой `#название`, кнопка «создать комнату». Имя не ищет и не открывает существующую комнату: оно не уникально, а доступ даёт владелец добавлением по нику. После создания открывается экран участников новой комнаты (ADR-078). ## Чат -Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн». +Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. У комнаты в той же кнопке стоит подпись «· участники N»; это число состава, не присутствующих онлайн. Без темы и «N онлайн». Лента: поток блоков сверху вниз; подряд идущие сообщения одного автора — без повтора автора над группой. На телефоне и планшете свои сообщения выровнены по правому краю, чужие — по левому; на десктопе все сообщения стоят слева (ADR-077). Десктоп определяется точным указателем с наведением, а не шириной окна: широкий планшет остаётся планшетом. Свой ник остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока всегда выровнен по левому краю. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`. @@ -62,7 +66,7 @@ ## Участники (`#/room//members`) -Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038). +Заголовок «участники» и список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038). ## Настройки (`#/settings`) diff --git a/web/app.css b/web/app.css index 46b7285..9d1fc49 100644 --- a/web/app.css +++ b/web/app.css @@ -306,6 +306,36 @@ input[type="password"] { font-size: 13px; } +/* новый чат — одно действие за раз (ADR-078). Подсказки стоят до формы, + чтобы смысл выбора был понятен до фокуса в поле */ + +.new { + max-width: 460px; +} + +.new__tabs { + margin-bottom: 18px; +} + +.new__panel[hidden] { + display: none; +} + +.new__copy { + max-width: 420px; + margin: 0 0 18px; + color: var(--text2); + font-size: 12px; + line-height: 1.6; +} + +.new__aside { + display: block; + margin-top: 8px; + color: var(--mute); + font-size: 11px; +} + /* каркас: сайдбар и экран */ .shell { @@ -717,6 +747,9 @@ input[type="password"] { /* чат: шапка, лента, ввод — docs/identity/screens.html */ .chat-title { + display: flex; + align-items: center; + gap: 8px; padding: 0; border: 0; border-radius: 0; @@ -728,6 +761,19 @@ input[type="password"] { cursor: pointer; } +.chat-title__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-title__members { + flex: none; + color: var(--mute); + font-size: 11px; +} + /* лента прижата к низу: короткая переписка не висит под шапкой */ .feed { @@ -1055,9 +1101,9 @@ input[type="password"] { --field-pad: 13px; } - /* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в - строке `@ник`: в рамке ввода «>» остаётся один, слева - (docs/identity/brief.md, «Компоновка») */ + /* на десктопе чат отправляет enter — кнопка `>` не нужна: в рамке + ввода она остаётся одна, слева (docs/identity/brief.md, «Компоновка»). + Остальные формы используют обычные текстовые `.button` */ .input__send { display: none; diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js index a760283..d56754e 100644 --- a/web/js/ui/chat.js +++ b/web/js/ui/chat.js @@ -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. diff --git a/web/js/ui/members.js b/web/js/ui/members.js index e76a0c7..b4aa5ed 100644 --- a/web/js/ui/members.js +++ b/web/js/ui/members.js @@ -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); diff --git a/web/js/ui/new.js b/web/js/ui/new.js index 4042ed5..3954667 100644 --- a/web/js/ui/new.js +++ b/web/js/ui/new.js @@ -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) { diff --git a/web/sw.js b/web/sw.js index c0f05e1..ee52f64 100644 --- a/web/sw.js +++ b/web/sw.js @@ -7,7 +7,7 @@ // Имя кэша содержит версию; версия — константа, она меняется при релизе, // и старые кэши уходят в activate. -const VERSION = "v7"; +const VERSION = "v8"; const CACHE = `bare-${VERSION}`; // Оболочка — всё, из чего клиент поднимается без сети. Список явный: