250 lines
7.8 KiB
JavaScript
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(", ")}`;
|
|
}
|