Files
bare/web/js/ui/chats.js
T
mayatnikovandClaude Opus 5 05586218e1 Этап 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
2026-08-22 22:18:13 +03:00

74 lines
2.2 KiB
JavaScript

// Список чатов в сайдбаре — docs/ui.md, «Список чатов».
//
// Секции «каналы» и «личные», порядок — по lastId по убыванию (его держит
// sync.chats). Пустая секция не рисуется.
import * as sync from "../sync.js";
import { clear, el } from "./dom.js";
const SECTIONS = [
["room", "каналы"],
["dm", "личные"],
];
// mount рисует список в root и держит его в актуальном виде, пока экран
// жив. Отдаёт отписку.
export function mount(root, ctx, active) {
// Событий «chats» приходит больше одного подряд; рисует последнее.
let generation = 0;
const paint = async () => {
const mine = ++generation;
let list;
try {
list = await sync.chats();
} catch {
return;
}
if (mine !== generation) {
return;
}
clear(root);
for (const [type, title] of SECTIONS) {
const part = list.filter((chat) => chat.type === type);
if (part.length === 0) {
continue;
}
const items = el("ul", "items");
for (const chat of part) {
items.append(item(ctx, chat, active));
}
root.append(el("h2", "section", title), items);
}
};
const off = sync.on("chats", paint);
paint();
return off;
}
function item(ctx, chat, active) {
const row = el("li");
const button = el("button", "item");
button.type = "button";
if (chat.id === active) {
// Активный чат — инверсия (docs/identity/brief.md).
button.classList.add("is-active");
button.setAttribute("aria-current", "true");
}
button.append(el("span", "item__name", sigil(chat) + chat.title));
if (chat.unread > 0) {
button.append(el("span", "n", String(chat.unread)));
}
button.addEventListener("click", () => ctx.go(hashOf(chat)));
row.append(button);
return row;
}
// Сигил ставит экран: в базе чат зовётся без «@» и «#» (docs/storage.md).
function sigil(chat) {
return chat.type === "dm" ? "@" : "#";
}
function hashOf(chat) {
return chat.type === "dm" ? `#/dm/${chat.peer}` : `#/room/${chat.roomId}`;
}