Files
bare/web/js/ui/members.js
T

250 lines
7.8 KiB
JavaScript

// Участники комнаты — 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(el("h2", "section", "участники"), 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(", ")}`;
}