Этап 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:
+91
-1
@@ -393,7 +393,20 @@ input[type="password"] {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* имя комнаты бывает длиной в 64 символа (ADR-018): в шапке оно
|
||||
обрезается, как и в строке сайдбара, — иначе страница едет вбок */
|
||||
|
||||
.head .chat-title,
|
||||
.head .title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.back {
|
||||
flex: none;
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--edge);
|
||||
@@ -444,12 +457,73 @@ input[type="password"] {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.fp-label {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 11px;
|
||||
color: var(--stone);
|
||||
}
|
||||
|
||||
.block--first > .fp-label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.fp-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
/* участники комнаты — docs/ui.md, «Участники» */
|
||||
|
||||
.members {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 28px;
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.member__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member .tag {
|
||||
color: var(--stone);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.member .link {
|
||||
margin-left: auto;
|
||||
color: var(--mute);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* строка ввода как отдельная форма: «новый чат» и «добавить участника» */
|
||||
|
||||
.form--row + .form--row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.form--row .button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* две кнопки подряд не слипаются в двойную рамку */
|
||||
|
||||
.block > .button + .button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* чат: шапка, лента, ввод — docs/identity/screens.html */
|
||||
|
||||
.chat-title {
|
||||
@@ -663,6 +737,13 @@ input[type="password"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ввод заблокирован предупреждением о ключе: кнопка «>» тоже гаснет */
|
||||
|
||||
.input__send[disabled] {
|
||||
color: var(--stone);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.counter,
|
||||
.enter {
|
||||
flex: none;
|
||||
@@ -675,15 +756,24 @@ input[type="password"] {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* полоса над вводом: нет соединения — stone, отказ отправки — mark */
|
||||
/* полоса над вводом: нет соединения — stone, отказ отправки и смена
|
||||
ключа — mark. У полосы про ключ есть кнопка, поэтому строка — flex */
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 8px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--stone);
|
||||
}
|
||||
|
||||
.bar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bar--mark {
|
||||
color: var(--mark);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,40 @@ export function removeContact(nick) {
|
||||
return request("DELETE", `/api/contacts/${encodeURIComponent(nick)}`);
|
||||
}
|
||||
|
||||
// --- комнаты -----------------------------------------------------------
|
||||
|
||||
// rooms — комнаты, где мы участники, каждая с нашим текущим завёрнутым
|
||||
// ключом (docs/protocol.md, «Комнаты»).
|
||||
export function rooms() {
|
||||
return request("GET", "/api/rooms");
|
||||
}
|
||||
|
||||
// createRoom заводит комнату. Идентификатор генерирует клиент: ключ
|
||||
// заворачивается до запроса и привязан к roomId (ADR-037). Занятый
|
||||
// идентификатор — 409 room_conflict, берётся новый.
|
||||
//
|
||||
// X-Device передаётся, чтобы это же устройство не получило комнату ещё
|
||||
// и событием: она приходит ответом (docs/protocol.md, «Комнаты»).
|
||||
export function createRoom(device, body) {
|
||||
return request("POST", "/api/rooms", body, { device });
|
||||
}
|
||||
|
||||
// changeMembers — смена состава и rekey одним запросом (ADR-018).
|
||||
export function changeMembers(id, body) {
|
||||
return request("POST", `/api/rooms/${encodeURIComponent(id)}/members`, body);
|
||||
}
|
||||
|
||||
// leaveRoom — выход из комнаты. X-Device передаётся по той же причине,
|
||||
// что и при создании: комната уходит из списка здесь же, а другим
|
||||
// устройствам вышедшего сервер шлёт room_left (ADR-041).
|
||||
export function leaveRoom(device, id) {
|
||||
return request("POST", `/api/rooms/${encodeURIComponent(id)}/leave`, undefined, { device });
|
||||
}
|
||||
|
||||
export function removeRoom(id) {
|
||||
return request("DELETE", `/api/rooms/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
// --- сообщения ---------------------------------------------------------
|
||||
|
||||
// sendMessage отдаёт конверт серверу; from и ts он поставит сам (ADR-017).
|
||||
@@ -208,6 +242,10 @@ export function ack(device, ids) {
|
||||
export function stream(device, handlers) {
|
||||
const source = new EventSource(`/api/events?device=${encodeURIComponent(device)}`);
|
||||
source.addEventListener("msg", (event) => handlers.msg(parse(event.data)));
|
||||
// room и room_left в очередь не кладутся: пропуск во время офлайна
|
||||
// чинится перечитыванием GET /api/rooms после ready (docs/protocol.md).
|
||||
source.addEventListener("room", (event) => handlers.room(parse(event.data)));
|
||||
source.addEventListener("room_left", (event) => handlers.roomLeft(parse(event.data)));
|
||||
source.addEventListener("ready", () => handlers.ready());
|
||||
source.addEventListener("error", () => handlers.error(source.readyState === EventSource.CLOSED));
|
||||
return () => source.close();
|
||||
|
||||
@@ -14,6 +14,8 @@ const INFO_AUTH = "bare-auth-v1";
|
||||
const INFO_KEK = "bare-kek-v1";
|
||||
const BLOB_AAD = "bare-blob-v1|";
|
||||
const DM_SALT = "bare-dm-v1";
|
||||
const WRAP_SALT = "bare-wrap-v1";
|
||||
const ROOM_AAD = "bare-roomkey-v1|";
|
||||
const MSG_AAD = "bare-msg-v1|";
|
||||
|
||||
// DM_KEY_ID — keyId личного чата: ключ выводится из ECDH, отдельного
|
||||
@@ -24,6 +26,10 @@ export const DM_KEY_ID = "dm";
|
||||
export const SECRET_LEN = 32;
|
||||
const IV_LEN = 12;
|
||||
|
||||
// ROOM_KEY_LEN — ключ комнаты: 32 случайных байта (docs/crypto.md,
|
||||
// «Комната»).
|
||||
export const ROOM_KEY_LEN = 32;
|
||||
|
||||
// ID_LEN — deviceId, keyId и roomId устроены одинаково: 16 случайных
|
||||
// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»).
|
||||
const ID_LEN = 16;
|
||||
@@ -290,6 +296,90 @@ export async function dmKey(privateKey, peerPublicJwk, me, peer) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- комната -----------------------------------------------------------
|
||||
|
||||
// roomLabel — метка комнаты для AAD сообщения: "room:" + roomId. Совпадает
|
||||
// с ключом чата в IndexedDB, но собирается здесь: crypto.js не знает о базе.
|
||||
export function roomLabel(roomId) {
|
||||
return `room:${roomId}`;
|
||||
}
|
||||
|
||||
// newRoomKey — новый ключ комнаты: 32 случайных байта и случайный keyId
|
||||
// (docs/crypto.md, «Комната»). Сырые байты живут до конца заворачивания,
|
||||
// потом распространитель импортирует их себе non-extractable и затирает.
|
||||
export function newRoomKey() {
|
||||
return { keyId: newId(), bytes: random(ROOM_KEY_LEN) };
|
||||
}
|
||||
|
||||
// importRoomKey — ключ комнаты как non-extractable AES-GCM-256. Наружу
|
||||
// он больше не выходит: в IndexedDB кладётся объект CryptoKey.
|
||||
export function importRoomKey(bytes) {
|
||||
return subtle.importKey("raw", bytes, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
// roomAad и wrapKey — ровно то, что написано в docs/crypto.md,
|
||||
// «Заворачивание участнику». Заворачивание самому себе идёт этим же кодом:
|
||||
// ECDH(myPrivate, myPublic), без исключений.
|
||||
function roomAad({ roomId, keyId, from, to }) {
|
||||
return utf8(`${ROOM_AAD}${roomId}|${keyId}|${from}|${to}`);
|
||||
}
|
||||
|
||||
async function wrapKey(privateKey, peerPublicJwk, roomId, keyId) {
|
||||
const publicKey = await importPublic(peerPublicJwk);
|
||||
const shared = await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256);
|
||||
const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
|
||||
wipe(new Uint8Array(shared));
|
||||
return subtle.deriveKey(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: utf8(WRAP_SALT),
|
||||
info: utf8(`${ROOM_AAD}${roomId}|${keyId}`),
|
||||
},
|
||||
material,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
// wrapRoomKey заворачивает сырые байты ключа комнаты участнику. Отдаёт
|
||||
// запись keys[] запроса: {to, iv, ct} (docs/protocol.md, «Типы»).
|
||||
export async function wrapRoomKey(privateKey, memberPublicJwk, { roomId, keyId, from, to }, roomKey) {
|
||||
const key = await wrapKey(privateKey, memberPublicJwk, roomId, keyId);
|
||||
const iv = random(IV_LEN);
|
||||
const ct = await subtle.encrypt(
|
||||
{ name: "AES-GCM", iv, additionalData: roomAad({ roomId, keyId, from, to }) },
|
||||
key,
|
||||
roomKey,
|
||||
);
|
||||
return { to, iv: b64url(iv), ct: b64url(ct) };
|
||||
}
|
||||
|
||||
// unwrapRoomKey разворачивает завёрнутый нам ключ той же схемой и сразу
|
||||
// импортирует его non-extractable: сырые байты дальше не идут.
|
||||
// Публичный ключ отправителя проходит через TOFU до вызова (ADR-016).
|
||||
export async function unwrapRoomKey(privateKey, senderPublicJwk, { roomId, keyId, from, to, iv, ct }) {
|
||||
const key = await wrapKey(privateKey, senderPublicJwk, roomId, keyId);
|
||||
const nonce = unb64url(iv);
|
||||
if (nonce.length !== IV_LEN) {
|
||||
throw new Error("iv — не 12 байт");
|
||||
}
|
||||
const plain = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: nonce, additionalData: roomAad({ roomId, keyId, from, to }) },
|
||||
key,
|
||||
unb64url(ct),
|
||||
);
|
||||
const bytes = new Uint8Array(plain);
|
||||
if (bytes.length !== ROOM_KEY_LEN) {
|
||||
wipe(bytes);
|
||||
throw new Error("ключ комнаты — не 32 байта");
|
||||
}
|
||||
const imported = await importRoomKey(bytes);
|
||||
wipe(bytes);
|
||||
return imported;
|
||||
}
|
||||
|
||||
// --- сообщение ---------------------------------------------------------
|
||||
|
||||
// messageAad привязывает открытые поля конверта к шифротексту: подмена
|
||||
|
||||
+107
-3
@@ -118,9 +118,14 @@ export function peerOf(chatId) {
|
||||
return chatId.startsWith(DM) ? chatId.slice(DM.length) : null;
|
||||
}
|
||||
|
||||
// roomIdOf — какая комната; у личного чата комнаты нет.
|
||||
export function roomIdOf(chatId) {
|
||||
return chatId.startsWith(ROOM) ? chatId.slice(ROOM.length) : null;
|
||||
}
|
||||
|
||||
// blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»:
|
||||
// сигил ставит экран. Комнате имя приходит из GET /api/rooms (этап 3),
|
||||
// до этого вместо имени стоит идентификатор.
|
||||
// сигил ставит экран. Комнате имя, владелец и состав приходят из
|
||||
// GET /api/rooms и события room; до этого вместо имени стоит идентификатор.
|
||||
export function blankChat(id) {
|
||||
const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false };
|
||||
const peer = peerOf(id);
|
||||
@@ -161,6 +166,39 @@ export function putChat(record) {
|
||||
return put("chats", record);
|
||||
}
|
||||
|
||||
// mergeChat правит поля чата, не трогая ленту и счётчики: чтение и запись
|
||||
// одной транзакцией, чтобы не разъехаться с параллельным markRead.
|
||||
// Недостающая запись заводится. Отдаёт, изменилось ли что-нибудь.
|
||||
export async function mergeChat(id, patch) {
|
||||
const db = await open();
|
||||
const tx = db.transaction("chats", "readwrite");
|
||||
const store = tx.objectStore("chats");
|
||||
const known = await value(store.get(id));
|
||||
const record = known ?? blankChat(id);
|
||||
// Новую запись пишем всегда: она могла совпасть с пустой по всем полям
|
||||
// патча и осталась бы ненаписанной.
|
||||
let changed = known === undefined;
|
||||
for (const [key, next] of Object.entries(patch)) {
|
||||
if (same(record[key], next)) {
|
||||
continue;
|
||||
}
|
||||
record[key] = next;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
store.put(record);
|
||||
}
|
||||
await done(tx);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function same(a, b) {
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return a.length === b.length && a.every((item, i) => item === b[i]);
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
// markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает
|
||||
// к последнему сообщению. Обе величины локальные, на сервер не уходят
|
||||
// (docs/storage.md).
|
||||
@@ -307,6 +345,23 @@ export async function pendingMessages() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// undecryptable — всё, что не удалось расшифровать и что хранит raw для
|
||||
// повторной попытки (docs/storage.md). Индекса по этому в схеме нет, значит
|
||||
// проход курсором; попытка повторяется редко: при подтверждении ключа
|
||||
// и при появлении недостающего ключа комнаты.
|
||||
export async function undecryptable() {
|
||||
const db = await open();
|
||||
const store = db.transaction("messages", "readonly").objectStore("messages");
|
||||
const out = [];
|
||||
await cursor(store.openCursor(), (record) => {
|
||||
if (record.undecryptable && record.raw) {
|
||||
out.push(record);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// cursor обходит курсор, пока step не скажет «хватит».
|
||||
function cursor(request, step) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -322,10 +377,59 @@ function cursor(request, step) {
|
||||
});
|
||||
}
|
||||
|
||||
// --- ключи комнат -------------------------------------------------------
|
||||
|
||||
// Ключ хранилища roomKeys — [roomId, keyId]; массив больше любой строки,
|
||||
// поэтому [roomId, []] — верхняя граница всех ключей комнаты, а [roomId] —
|
||||
// нижняя (тот же приём, что и в индексе "chat").
|
||||
function roomRange(roomId) {
|
||||
return IDBKeyRange.bound([roomId], [roomId, []]);
|
||||
}
|
||||
|
||||
export function roomKey(roomId, keyId) {
|
||||
return get("roomKeys", [roomId, keyId]);
|
||||
}
|
||||
|
||||
// roomKeysOf — все ключи комнаты по возрастанию receivedAt: последний
|
||||
// и есть текущий (docs/storage.md, ADR-042).
|
||||
export async function roomKeysOf(roomId) {
|
||||
const db = await open();
|
||||
const store = db.transaction("roomKeys", "readonly").objectStore("roomKeys");
|
||||
const list = await value(store.getAll(roomRange(roomId)));
|
||||
return list.sort((a, b) => a.receivedAt - b.receivedAt);
|
||||
}
|
||||
|
||||
// saveRoomKey кладёт ключ комнаты, если такого ещё нет; отдаёт, случилось ли
|
||||
// это. Клиент держит все ключи комнаты и расшифровывает любым известным
|
||||
// (ADR-018), поэтому уже сохранённый ключ не перезаписывается.
|
||||
//
|
||||
// receivedAt строго больше времени всех прежних ключей комнаты: текущий ключ
|
||||
// — последний полученный этим устройством, а два rekey подряд укладываются
|
||||
// в одну миллисекунду (ADR-042, docs/storage.md).
|
||||
export async function saveRoomKey({ roomId, keyId, key, from }) {
|
||||
const db = await open();
|
||||
const tx = db.transaction("roomKeys", "readwrite");
|
||||
const store = tx.objectStore("roomKeys");
|
||||
const list = await value(store.getAll(roomRange(roomId)));
|
||||
let receivedAt = Date.now();
|
||||
let known = false;
|
||||
for (const record of list) {
|
||||
known = known || record.keyId === keyId;
|
||||
if (record.receivedAt >= receivedAt) {
|
||||
receivedAt = record.receivedAt + 1;
|
||||
}
|
||||
}
|
||||
if (!known) {
|
||||
store.put({ roomId, keyId, key, from, receivedAt });
|
||||
}
|
||||
await done(tx);
|
||||
return !known;
|
||||
}
|
||||
|
||||
// --- собеседники --------------------------------------------------------
|
||||
|
||||
// peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом
|
||||
// получении ключа; сверка изменившегося ключа и pending — этап 3.
|
||||
// получении ключа; изменившийся ключ ложится в pending и ждёт подтверждения.
|
||||
export function peer(nick) {
|
||||
return get("peers", nick);
|
||||
}
|
||||
|
||||
+20
-4
@@ -26,6 +26,7 @@ import { DESKTOP, clear, wide } from "./ui/dom.js";
|
||||
import { renderAuth } from "./ui/auth.js";
|
||||
import { renderChat } from "./ui/chat.js";
|
||||
import { renderContact } from "./ui/contact.js";
|
||||
import { renderMembers } from "./ui/members.js";
|
||||
import { renderNew } from "./ui/new.js";
|
||||
import { renderSettings } from "./ui/settings.js";
|
||||
import { frame } from "./ui/shell.js";
|
||||
@@ -69,9 +70,11 @@ const ctx = {
|
||||
|
||||
// --- роутинг -----------------------------------------------------------
|
||||
|
||||
// route разбирает hash. Маршруты — docs/ui.md, «Каркас»; комнаты придут
|
||||
// на этапе 3, до тех пор `#/room/…` — неизвестный путь и ведёт в список.
|
||||
// route разбирает hash. Маршруты — docs/ui.md, «Каркас». Ник — форма
|
||||
// ADR-019, идентификатор комнаты — 16 случайных байт base64url
|
||||
// (docs/crypto.md, «Идентификаторы»). Всё, что не разобралось, — список.
|
||||
const NICK_ROUTE = /^#\/(dm|contact)\/([a-z0-9_]{2,32})$/;
|
||||
const ROOM_ROUTE = /^#\/room\/([A-Za-z0-9_-]{22})(\/members)?$/;
|
||||
|
||||
function route() {
|
||||
const hash = location.hash || "#/";
|
||||
@@ -85,6 +88,10 @@ function route() {
|
||||
if (nick) {
|
||||
return { kind: nick[1], nick: nick[2] };
|
||||
}
|
||||
const room = ROOM_ROUTE.exec(hash);
|
||||
if (room) {
|
||||
return { kind: room[2] ? "members" : "room", roomId: room[1] };
|
||||
}
|
||||
return { kind: "root" };
|
||||
}
|
||||
|
||||
@@ -101,7 +108,14 @@ async function render() {
|
||||
}
|
||||
const where = route();
|
||||
// На десктопе `#/` показывает первый чат — тот, что вверху списка.
|
||||
let chatId = where.kind === "dm" ? sync.dmChatId(where.nick) : null;
|
||||
// У экранов «карточка контакта» и «участники» открытого чата нет:
|
||||
// в сайдбаре не выделен никто.
|
||||
let chatId = null;
|
||||
if (where.kind === "dm") {
|
||||
chatId = sync.dmChatId(where.nick);
|
||||
} else if (where.kind === "room") {
|
||||
chatId = sync.roomChatId(where.roomId);
|
||||
}
|
||||
if (where.kind === "root" && wide()) {
|
||||
const list = await sync.chats().catch(() => []);
|
||||
if (mine !== state.paint) {
|
||||
@@ -123,7 +137,9 @@ async function render() {
|
||||
} else if (where.kind === "new") {
|
||||
renderNew(main, ctx);
|
||||
} else if (where.kind === "contact") {
|
||||
renderContact(main, ctx, where.nick);
|
||||
parts.push(renderContact(main, ctx, where.nick));
|
||||
} else if (where.kind === "members") {
|
||||
parts.push(renderMembers(main, ctx, where.roomId));
|
||||
} else if (chatId !== null) {
|
||||
parts.push(renderChat(main, ctx, chatId));
|
||||
}
|
||||
|
||||
+771
-60
File diff suppressed because it is too large
Load Diff
+162
-22
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user