Сервер: комнаты, состав и завёрнутые ключи; смена состава и 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
114 lines
3.5 KiB
JavaScript
114 lines
3.5 KiB
JavaScript
// Новый чат — 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");
|
|
// Место под ошибку одно на оба поля: строка состояния у экрана одна
|
|
// (ADR-028).
|
|
const note = message();
|
|
|
|
const dm = row("@ник");
|
|
const room = row("#имя комнаты");
|
|
room.field.maxLength = ROOM_NAME_MAX;
|
|
|
|
dm.form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
if (dm.go.disabled) {
|
|
return;
|
|
}
|
|
setNote(note, "");
|
|
// Ник вводят как в списке: с «@» или без. Регистр не хранится —
|
|
// ники строчные (ADR-019).
|
|
const nick = dm.field.value.trim().replace(/^@/, "").toLowerCase();
|
|
if (nick === "") {
|
|
dm.field.focus();
|
|
return;
|
|
}
|
|
dm.field.value = nick;
|
|
dm.go.disabled = true;
|
|
try {
|
|
await sync.openDm(nick);
|
|
ctx.go(`#/dm/${nick}`);
|
|
} catch (err) {
|
|
setError(note, ctx.errorText(err));
|
|
dm.field.focus();
|
|
} finally {
|
|
dm.go.disabled = false;
|
|
}
|
|
});
|
|
|
|
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);
|
|
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) {
|
|
const bar = el("div", "head");
|
|
const back = el("button", "back", "назад");
|
|
back.type = "button";
|
|
back.addEventListener("click", () => ctx.go("#/"));
|
|
bar.append(back, el("span", "title", "новый чат"));
|
|
return bar;
|
|
}
|