Этап 3: TOFU и комнаты — ключ комнаты, атомарный rekey, участники

Сервер: комнаты, состав и завёрнутые ключи; смена состава и rekey одним
атомарным запросом с проверкой keys[].to против итогового состава; обрезка
до двух последних ключей; передача владения по joined_at; события room
каждому со своим ключом и room_left удалённым; членство и keyId в проверках
сообщения; миграция 002.

Клиент: TOFU на всех путях, по которым публичный ключ доходит до клиента;
предупреждение о смене ключа с блокировкой отправки и повторной расшифровкой
сохранённого raw; заворачивание и разворачивание ключей комнаты, включая
себе — тем же кодом, без ветвления; расшифровка любым известным keyId;
экраны участников, создание комнаты, карточка контакта с двумя отпечатками.

ADR-037: roomId генерирует клиент. crypto.md вплетает roomId в заворачивание,
а оно происходит до запроса — создатель обязан привязать ключ к идентификатору,
которого по прежнему протоколу ещё не существовало.
ADR-039: завёрнутый ключ принимается только от участника. Иначе сервер
подставляет ключ, завёрнутый посторонним аккаунтом, TOFU молчит — ник
незнакомый, первый ключ запоминается молча, — и комната уезжает на ключ
сервера. Одно подменённое поле в ответе, без сговора и подмены кода.
ADR-041: долг по rekey — состояние комнаты, а не свойство события. Владелец,
офлайн в момент выхода участника, не узнавал о долге никогда, и комната
навсегда оставалась на ключе, который вышедший знает.
ADR-038, 040, 042, 043, 044: тексты экранов комнат, снятие pending при
возврате прежнего ключа, порядок ключей, форма раньше прав, экран покинутой
комнаты.

Приёмка на боевом: комната на троих, добавленный четвёртый читает только
новое, вышедший после rekey новых не получает и писать не может,
keys_mismatch, key_exists, not_owner, owner, room_conflict, чужой X-Device.
Подмена public_key в базе даёт у собеседника предупреждение и блокирует
отправку — проверено в Chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
2026-08-22 22:18:13 +03:00
co-authored by Claude Opus 5
parent 63a7a1ef52
commit 05586218e1
38 changed files with 4692 additions and 201 deletions
+162 -22
View File
@@ -16,11 +16,20 @@ const DAY_LONG = new Intl.DateTimeFormat("ru-RU", { weekday: "long", day: "numer
const DAY_SHORT = new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short" });
const TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" });
// Тексты нерасшифрованного — docs/ui.md, «Чат». Ключа комнаты нет —
// это про комнату; всё остальное в личном чате означает чужой ключ.
// Тексты нерасшифрованного — docs/ui.md, «Чат». Строк там две, а причин
// в записи три (docs/storage.md): «нет ключа комнаты» — это unknown_key
// в комнате, всё остальное сводится к «ключ изменился».
const NO_ROOM_KEY = "не удалось расшифровать: нет ключа комнаты";
const KEY_CHANGED = "не удалось расшифровать: ключ изменился";
// Сколько символов имени комнаты попадает в подсказку ввода. Строка ввода
// растёт под placeholder так же, как под текст, и имя в 64 символа (ADR-018)
// занимало бы три строки. Имя укорачивается многоточием, как в шапке, только
// разметкой: text-overflow к placeholder не применяется. Двенадцать —
// столько, чтобы «сообщение в #имя…» умещалось в одну строку на самом
// узком из целевых экранов (360 px).
const NAME_IN_HINT = 12;
// Насколько далеко от низа ленты человек ещё считается «внизу»: пришедшее
// сообщение подматывает ленту только тогда, когда он и так смотрит конец.
const NEAR_BOTTOM = 80;
@@ -32,8 +41,18 @@ export function renderChat(root, ctx, chatId) {
chatId,
me: ctx.me.nick,
peer: sync.peerOf(chatId),
roomId: sync.roomIdOf(chatId),
limit: ctx.config?.maxMessageChars ?? LIMIT,
alive: true,
// Имя комнаты; до чтения записи чата вместо него идентификатор,
// как в db.blankChat.
name: sync.roomIdOf(chatId),
// Ключ собеседника изменился и ждёт подтверждения: ввод заблокирован
// (ADR-016).
blocked: false,
// Комнаты у нас больше нет: вышли сами, убрал владелец, комната
// удалена. Ввод заблокирован, лента остаётся (ADR-044).
gone: false,
// Лента: записи по возрастанию id и их строки в разметке.
items: [],
nodes: new Map(),
@@ -58,6 +77,18 @@ export function renderChat(root, ctx, chatId) {
}
});
const offNet = sync.on("net", () => paintBar(view));
// Доверие к ключу собеседника меняет полосу и ввод; имя комнаты приходит
// из GET /api/rooms и события room, иногда позже первой отрисовки.
const offPeers = sync.on("peers", (detail) => {
if (view.peer !== null && detail.nick === view.peer) {
run(view, () => checkPeer(view));
}
});
const offRooms = sync.on("rooms", (detail) => {
if (view.roomId !== null && detail.id === view.roomId) {
run(view, () => refreshRoom(view));
}
});
const media = matchMedia(DESKTOP);
const onMedia = () => paint(view, true);
media.addEventListener("change", onMedia);
@@ -68,6 +99,8 @@ export function renderChat(root, ctx, chatId) {
view.alive = false;
offMessages();
offNet();
offPeers();
offRooms();
media.removeEventListener("change", onMedia);
};
}
@@ -81,20 +114,65 @@ function run(view, task) {
// --- разметка -----------------------------------------------------------
// head — шапка: имя чата, по нажатию — карточка контакта. «назад» слева
// нужен там, где виден один экран за раз; на десктопе его прячет CSS.
// head — шапка: имя чата, по нажатию — участники комнаты или карточка
// контакта (docs/ui.md, «Чат»). «назад» слева нужен там, где виден один
// экран за раз; на десктопе его прячет CSS.
function head(view) {
const bar = el("div", "head");
const back = el("button", "back back--chat", "назад");
back.type = "button";
back.addEventListener("click", () => view.ctx.go("#/"));
const title = el("button", "chat-title", `@${view.peer}`);
title.type = "button";
title.addEventListener("click", () => view.ctx.go(`#/contact/${view.peer}`));
bar.append(back, title);
view.title = el("button", "chat-title", titleText(view));
view.title.type = "button";
view.title.addEventListener("click", () => view.ctx.go(view.roomId !== null
? `#/room/${view.roomId}/members`
: `#/contact/${view.peer}`));
bar.append(back, view.title);
return bar;
}
function titleText(view) {
return view.roomId !== null ? `#${view.name}` : `@${view.peer}`;
}
// shortName — имя комнаты для подсказки ввода: длинное обрезается
// многоточием. Считается символами, а не единицами utf-16: имя ограничено
// символами (docs/protocol.md), и разрезать пару посередине незачем.
function shortName(name) {
const chars = Array.from(name);
return chars.length > NAME_IN_HINT ? `${chars.slice(0, NAME_IN_HINT).join("")}` : name;
}
// refreshRoom обновляет имя комнаты в шапке и placeholder ввода — имя
// приходит от сервера и бывает известно позже первой отрисовки — и состояние
// членства: комната, из состава которой нас больше нет, гасит ввод
// и показывает полосу (ADR-044).
async function refreshRoom(view, known) {
if (view.roomId === null) {
return;
}
let record = known;
if (record === undefined) {
try {
record = await sync.chat(view.chatId);
} catch {
return;
}
}
if (!view.alive) {
return;
}
view.name = record?.title || view.roomId;
view.title.textContent = titleText(view);
view.field.placeholder = `сообщение в #${shortName(view.name)}`;
// Скрытая запись комнаты — это room_left или собственный выход:
// отправлять больше некуда, и сервер ответил бы not_member.
view.gone = record?.hidden === true;
view.field.disabled = view.gone;
view.send.disabled = view.gone;
paintBar(view);
}
// composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>»
// цветом mark. Enter отправляет только на десктопе; на мобильном он делает
// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»).
@@ -118,10 +196,10 @@ function composer(view) {
view.counter = el("span", "counter");
view.counter.hidden = true;
const send = el("button", "input__send", ">");
send.type = "submit";
view.send = el("button", "input__send", ">");
view.send.type = "submit";
row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), send);
row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send);
form.append(view.bar, row);
view.field.addEventListener("input", () => count(view));
@@ -152,7 +230,7 @@ function count(view) {
function submit(view) {
const text = view.field.value;
if (text.trim() === "") {
if (view.blocked || view.gone || text.trim() === "") {
return;
}
view.field.value = "";
@@ -174,6 +252,11 @@ async function load(view) {
if (!view.alive) {
return;
}
await refreshRoom(view, record ?? null);
await checkPeer(view);
if (!view.alive) {
return;
}
view.items = list;
view.newId = firstUnread(record, list, view.me);
paint(view, true);
@@ -342,7 +425,7 @@ function text(view, record) {
const node = el("div", "text");
if (record.text === null) {
node.classList.add("text--none");
node.textContent = view.peer === null && record.undecryptable === "unknown_key"
node.textContent = view.roomId !== null && record.undecryptable === "unknown_key"
? NO_ROOM_KEY
: KEY_CHANGED;
return node;
@@ -362,25 +445,82 @@ function text(view, record) {
return node;
}
// paintBar — полоса над вводом. Причина одна за раз: отказ отправки
// checkPeer — состояние доверия к ключу собеседника. Ключ изменился
// и ждёт подтверждения — ввод заблокирован до «доверять новому ключу»
// (docs/ui.md, «Чат»). В комнате блокировать нечего: ключ там симметричный,
// а чьи ключи мешают rekey, показывает экран участников.
async function checkPeer(view) {
if (view.peer === null) {
return;
}
let record = null;
try {
record = await sync.peer(view.peer);
} catch {
// Базы нет — считаем ключ прежним: отправка не блокируется.
}
if (!view.alive) {
return;
}
view.blocked = !!record?.pending;
// Ввод заблокирован целиком: и поле, и кнопка «>» на мобильном.
view.field.disabled = view.blocked;
view.send.disabled = view.blocked;
paintBar(view);
}
// paintBar — полоса над вводом. Причина одна за раз (ADR-033). Порядок:
// комнаты у нас больше нет — перебивает всё, отправлять некуда (ADR-044);
// предупреждение о ключе — только оно блокирует ввод в личном чате,
// и пока оно висит, повторять отправку нечем (ADR-038); отказ отправки
// перебивает «нет соединения», потому что он про конкретное сообщение
// и уходит при следующей попытке (ADR-033).
// и уходит при следующей попытке.
function paintBar(view) {
if (view.gone) {
band(view, "bar bar--mark", "вы больше не участник комнаты", false);
return;
}
if (view.blocked) {
band(view, "bar bar--mark", `ключ @${view.peer} изменился. сверьте отпечаток лично.`, true);
return;
}
const failed = lastFailed(view);
if (failed) {
view.bar.className = "bar bar--mark";
view.bar.textContent = failed.error;
view.bar.hidden = false;
band(view, "bar bar--mark", failed.error, false);
return;
}
if (!sync.online()) {
view.bar.className = "bar";
view.bar.textContent = "нет соединения";
view.bar.hidden = false;
band(view, "bar", "нет соединения", false);
return;
}
clear(view.bar);
view.bar.hidden = true;
view.bar.textContent = "";
}
// band — сама полоса. Кнопка «доверять новому ключу» стоит в ней же:
// подтверждение — единственный выход из состояния (ADR-016).
function band(view, className, caption, trust) {
clear(view.bar);
view.bar.className = className;
view.bar.append(el("span", null, caption));
if (trust) {
const yes = el("button", "link", "доверять новому ключу");
yes.type = "button";
yes.addEventListener("click", () => {
yes.disabled = true;
run(view, async () => {
try {
await sync.trustKey(view.peer);
} finally {
// Удалось — полосу перерисует событие «peers»; нет — кнопка
// снова готова к нажатию.
yes.disabled = false;
}
});
});
view.bar.append(yes);
}
view.bar.hidden = false;
}
// lastFailed — последнее своё неотправленное сообщение с текстом отказа
+1 -1
View File
@@ -1,7 +1,7 @@
// Список чатов в сайдбаре — docs/ui.md, «Список чатов».
//
// Секции «каналы» и «личные», порядок — по lastId по убыванию (его держит
// sync.chats). Пустая секция не рисуется: комнат до этапа 3 нет.
// sync.chats). Пустая секция не рисуется.
import * as sync from "../sync.js";
import { clear, el } from "./dom.js";
+74 -18
View File
@@ -1,33 +1,38 @@
// Карточка контакта — docs/ui.md, «Карточка контакта».
//
// Смена ключа собеседника и «доверять новому ключу» появятся вместе
// с TOFU (этап 3, ADR-016): до тех пор у записи peers нет pending.
// Отпечаток доверенного ключа, а если ключ ника изменился и ждёт
// подтверждения — оба отпечатка и «доверять новому ключу» (ADR-016).
import * as sync from "../sync.js";
import { fingerprintGroups } from "../crypto.js";
import { button, el, message, setError, setNote } from "./dom.js";
import { button, clear, el, message, setError, setNote } from "./dom.js";
// renderContact рисует карточку в root и отдаёт отписку.
export function renderContact(root, ctx, nick) {
const view = {
ctx,
nick,
alive: true,
// Событий «peers» приходит больше одного подряд; рисует последнее.
generation: 0,
};
root.append(head(ctx, nick));
const body = el("div", "body settings");
view.card = el("section", "block block--first");
body.append(view.card, remove(ctx, nick));
root.append(body);
const card = el("section", "block block--first");
body.append(card, remove(ctx, nick));
// Отпечаток лежит в записи TOFU; её может ещё не быть, если чат
// открыли до первого ключа.
sync.peer(nick).then((record) => {
if (!record?.fingerprint || !card.isConnected) {
return;
const off = sync.on("peers", (detail) => {
if (detail.nick === view.nick) {
paint(view);
}
const groups = fingerprintGroups(record.fingerprint);
card.append(
el("p", "fp", groups.slice(0, 8).join(" ")),
el("p", "fp", groups.slice(8).join(" ")),
el("p", "fp-hint", "сверьте с собеседником голосом или лично"),
);
}).catch(() => {});
});
paint(view);
return () => {
view.alive = false;
off();
};
}
function head(ctx, nick) {
@@ -39,6 +44,57 @@ function head(ctx, nick) {
return bar;
}
// paint рисует отпечатки. Записи TOFU может ещё не быть: чат открыли
// раньше, чем пришёл первый ключ.
async function paint(view) {
const mine = ++view.generation;
let record = null;
try {
record = await sync.peer(view.nick);
} catch {
// Базы нет — показывать нечего.
}
if (!view.alive || mine !== view.generation || !record?.fingerprint) {
return;
}
clear(view.card);
if (!record.pending) {
fingerprint(view.card, record.fingerprint, null);
view.card.append(el("p", "fp-hint", "сверьте с собеседником голосом или лично"));
return;
}
// Ключ изменился: показываем оба отпечатка с пометками, чтобы
// подтверждали не наугад (ADR-038).
fingerprint(view.card, record.fingerprint, "старый");
fingerprint(view.card, record.pending.fingerprint, "новый");
view.card.append(el("p", "fp-hint", "сверьте с собеседником голосом или лично"), trust(view));
}
// fingerprint — 64 hex группами по 4 в две строки (ADR-016).
function fingerprint(box, value, label) {
if (label) {
box.append(el("p", "fp-label", label));
}
const groups = fingerprintGroups(value);
box.append(el("p", "fp", groups.slice(0, 8).join(" ")), el("p", "fp", groups.slice(8).join(" ")));
}
// trust — «доверять новому ключу»: ключ из pending становится основным,
// и всё, что в него упиралось, повторяется (ADR-016).
function trust(view) {
const yes = button("доверять новому ключу");
yes.addEventListener("click", async () => {
yes.disabled = true;
try {
await sync.trustKey(view.nick);
} catch {
// Не вышло — запись цела, экран перерисуется с теми же ключами.
yes.disabled = false;
}
});
return yes;
}
// remove — «убрать из списка»: строка контакта уходит с сервера, история
// на устройстве остаётся (ADR-019).
function remove(ctx, nick) {
+249
View File
@@ -0,0 +1,249 @@
// Участники комнаты — docs/ui.md, «Участники».
//
// Состав, владелец и то, чей ключ мешает rekey, приходят из sync: экран
// не пишет в базу и не ходит в сеть сам.
import * as sync from "../sync.js";
import { button, clear, confirmPanel, el, message, setError, setNote } from "./dom.js";
// renderMembers рисует экран в root и отдаёт отписку.
export function renderMembers(root, ctx, roomId) {
const view = {
ctx,
roomId,
chatId: sync.roomChatId(roomId),
me: ctx.me.nick,
alive: true,
// Событий «rooms» приходит больше одного подряд; рисует последнее.
generation: 0,
};
view.title = el("span", "title", "#");
root.append(head(view));
const body = el("div", "body settings");
// Полоса «нужен новый ключ комнаты» — единственный акцент экрана
// (docs/identity/brief.md).
view.warn = el("p", "bar bar--mark");
view.warn.hidden = true;
view.warn.setAttribute("aria-live", "polite");
const people = el("section", "block block--first");
view.list = el("ul", "members");
view.note = message();
people.append(view.list, add(view), view.note);
body.append(view.warn, people, exit(view));
root.append(body);
const off = sync.on("rooms", (detail) => {
if (detail.id === view.roomId) {
paint(view);
}
});
paint(view);
return () => {
view.alive = false;
off();
};
}
function head(view) {
const bar = el("div", "head");
const back = el("button", "back", "назад");
back.type = "button";
back.addEventListener("click", () => view.ctx.go(`#/room/${view.roomId}`));
bar.append(back, view.title);
return bar;
}
// --- разметка -----------------------------------------------------------
// add — строка ввода `@ник` и «добавить». Видна только владельцу: состав
// меняет он (ADR-018).
function add(view) {
const form = el("form", "form form--row");
form.noValidate = true;
form.hidden = true;
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 = "@ник";
field.autocapitalize = "off";
field.autocomplete = "off";
field.spellcheck = false;
line.append(prompt, field);
const go = el("button", "button", "добавить");
go.type = "submit";
form.append(line, go);
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (go.disabled) {
return;
}
setNote(view.note, "");
// Ник вводят как в списке: с «@» или без. Ники строчные (ADR-019).
const nick = field.value.trim().replace(/^@/, "").toLowerCase();
if (nick === "") {
field.focus();
return;
}
field.value = nick;
go.disabled = true;
try {
await sync.changeMembers(view.roomId, { add: [nick] });
field.value = "";
} catch (err) {
setError(view.note, failure(view.ctx, err));
field.focus();
} finally {
go.disabled = false;
}
});
view.add = form;
return form;
}
// exit — «выйти из комнаты» всем, «удалить комнату» владельцу
// (docs/ui.md, «Участники»).
function exit(view) {
const box = el("section", "block");
const note = message();
const leave = button("выйти из комнаты");
leave.addEventListener("click", async () => {
leave.disabled = true;
setNote(note, "");
try {
await sync.leaveRoom(view.roomId);
view.ctx.go("#/");
} catch (err) {
setError(note, failure(view.ctx, err));
leave.disabled = false;
}
});
const drop = button("удалить комнату");
drop.hidden = true;
const panel = confirmPanel("комната будет удалена у всех участников.", "удалить");
drop.addEventListener("click", () => {
setNote(note, "");
drop.hidden = true;
panel.root.hidden = false;
panel.yes.focus();
});
panel.no.addEventListener("click", () => {
panel.root.hidden = true;
drop.hidden = false;
drop.focus();
});
panel.yes.addEventListener("click", async () => {
panel.yes.disabled = true;
panel.no.disabled = true;
try {
await sync.deleteRoom(view.roomId);
view.ctx.go("#/");
} catch (err) {
setError(note, failure(view.ctx, err));
panel.yes.disabled = false;
panel.no.disabled = false;
panel.root.hidden = true;
drop.hidden = false;
}
});
view.drop = drop;
view.panel = panel.root;
box.append(leave, drop, panel.root, note);
return box;
}
// --- состояние ----------------------------------------------------------
// paint перечитывает комнату и рисует состав. Владение переходит к участнику
// с наименьшим joined_at (ADR-018), поэтому владельческие части экрана
// появляются и исчезают вместе с составом.
async function paint(view) {
const mine = ++view.generation;
let record = null;
try {
record = await sync.chat(view.chatId);
} catch {
// Базы нет — рисуем пустой состав: выйти из комнаты это не мешает.
}
if (!view.alive || mine !== view.generation) {
return;
}
const members = record?.members ?? [];
const owner = record?.owner ?? null;
const mineRoom = owner === view.me;
view.title.textContent = `#${record?.title || view.roomId}`;
clear(view.list);
for (const nick of members) {
view.list.append(person(view, nick, owner, mineRoom));
}
view.add.hidden = !mineRoom;
view.drop.hidden = !mineRoom || !view.panel.hidden;
if (!mineRoom) {
view.panel.hidden = true;
}
const blocked = sync.needsTrust(view.roomId);
view.warn.textContent = blocked.length > 0 ? trustText(blocked) : "";
view.warn.hidden = blocked.length === 0;
}
// person — строка участника: ник, пометка «владелец», «убрать» у владельца.
// Владельца убрать нельзя (docs/protocol.md, `400 owner`), поэтому кнопки
// в его строке нет.
function person(view, nick, owner, mineRoom) {
const row = el("li", "member");
row.append(el("span", "member__name", `@${nick}`));
if (nick === owner) {
row.append(el("span", "tag", "владелец"));
return row;
}
if (!mineRoom) {
return row;
}
const drop = el("button", "link", "убрать");
drop.type = "button";
drop.addEventListener("click", async () => {
drop.disabled = true;
setNote(view.note, "");
try {
await sync.changeMembers(view.roomId, { remove: [nick] });
} catch (err) {
setError(view.note, failure(view.ctx, err));
drop.disabled = false;
}
});
row.append(drop);
return row;
}
// failure — текст отказа. Неподтверждённый ключ обрывает смену состава
// до запроса, и состояние у него то же, что у несделанного rekey:
// комнате нужен новый ключ, а раздать его некому (ADR-016, ADR-038).
function failure(ctx, err) {
if (err instanceof sync.TrustNeeded) {
return trustText(err.nicks);
}
return ctx.errorText(err);
}
function trustText(nicks) {
return `нужен новый ключ комнаты: подтвердите ключ ${nicks.map((nick) => `@${nick}`).join(", ")}`;
}
+74 -29
View File
@@ -1,61 +1,106 @@
// Новый чат — docs/ui.md, «Новый чат». Строка `#имя комнаты` появится
// вместе с комнатами (этап 3): создавать пока нечего.
// Новый чат — docs/ui.md, «Новый чат». Две строки ввода: `@ник` открывает
// личный чат, `#имя комнаты` заводит комнату.
import * as sync from "../sync.js";
import { el, message, setError, setNote } from "./dom.js";
// Имя комнаты — до 64 символов (ADR-018, ADR-021). maxLength считает
// единицы UTF-16, а сервер — руны: за предел это не выпустит.
const ROOM_NAME_MAX = 64;
export function renderNew(root, ctx) {
root.append(head(ctx));
const body = el("div", "body");
const form = el("form", "form");
form.noValidate = true;
const row = el("div", "input");
const prompt = el("span", "p", ">");
prompt.setAttribute("aria-hidden", "true");
const field = el("input", "input__field");
field.type = "text";
field.placeholder = "@ник";
field.autocapitalize = "off";
field.autocomplete = "off";
field.spellcheck = false;
const go = el("button", "input__send", ">");
go.type = "submit";
row.append(prompt, field, go);
// Место под ошибку одно на оба поля: строка состояния у экрана одна
// (ADR-028).
const note = message();
form.append(row, note);
form.addEventListener("submit", async (event) => {
const dm = row("@ник");
const room = row("#имя комнаты");
room.field.maxLength = ROOM_NAME_MAX;
dm.form.addEventListener("submit", async (event) => {
event.preventDefault();
if (go.disabled) {
if (dm.go.disabled) {
return;
}
setNote(note, "");
// Ник вводят как в списке: с «@» или без. Регистр не хранится —
// ники строчные (ADR-019).
const nick = field.value.trim().replace(/^@/, "").toLowerCase();
const nick = dm.field.value.trim().replace(/^@/, "").toLowerCase();
if (nick === "") {
field.focus();
dm.field.focus();
return;
}
field.value = nick;
go.disabled = true;
dm.field.value = nick;
dm.go.disabled = true;
try {
await sync.openDm(nick);
ctx.go(`#/dm/${nick}`);
} catch (err) {
setError(note, ctx.errorText(err));
field.focus();
dm.field.focus();
} finally {
go.disabled = false;
dm.go.disabled = false;
}
});
body.append(form);
room.form.addEventListener("submit", async (event) => {
event.preventDefault();
if (room.go.disabled) {
return;
}
setNote(note, "");
// Имя вводят как в списке: с «#» или без. Регистр имени комнаты
// сохраняется — это её название, а не идентификатор.
const name = room.field.value.trim().replace(/^#/, "").trim();
if (name === "") {
room.field.focus();
return;
}
room.go.disabled = true;
try {
const chatId = await sync.createRoom(name);
if (chatId === null) {
room.field.focus();
return;
}
ctx.go(`#/room/${sync.roomIdOf(chatId)}`);
} catch (err) {
setError(note, ctx.errorText(err));
room.field.focus();
} finally {
room.go.disabled = false;
}
});
body.append(dm.form, room.form, note);
root.append(body);
field.focus();
dm.field.focus();
}
// row — строка ввода в стиле чата: рамка 1 px ink, слева «>» цветом mark
// (docs/identity/brief.md, «Компоновка»).
function row(placeholder) {
const form = el("form", "form form--row");
form.noValidate = true;
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 head(ctx) {