Сервер: комнаты, состав и завёрнутые ключи; смена состава и 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
74 lines
2.2 KiB
JavaScript
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}`;
|
|
}
|