Новый чат v1.1.2: разделить личные чаты и комнаты
This commit is contained in:
+49
-3
@@ -306,6 +306,36 @@ input[type="password"] {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* новый чат — одно действие за раз (ADR-078). Подсказки стоят до формы,
|
||||
чтобы смысл выбора был понятен до фокуса в поле */
|
||||
|
||||
.new {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.new__tabs {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.new__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.new__copy {
|
||||
max-width: 420px;
|
||||
margin: 0 0 18px;
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.new__aside {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--mute);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* каркас: сайдбар и экран */
|
||||
|
||||
.shell {
|
||||
@@ -717,6 +747,9 @@ input[type="password"] {
|
||||
/* чат: шапка, лента, ввод — docs/identity/screens.html */
|
||||
|
||||
.chat-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -728,6 +761,19 @@ input[type="password"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-title__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-title__members {
|
||||
flex: none;
|
||||
color: var(--mute);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* лента прижата к низу: короткая переписка не висит под шапкой */
|
||||
|
||||
.feed {
|
||||
@@ -1055,9 +1101,9 @@ input[type="password"] {
|
||||
--field-pad: 13px;
|
||||
}
|
||||
|
||||
/* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в
|
||||
строке `@ник`: в рамке ввода «>» остаётся один, слева
|
||||
(docs/identity/brief.md, «Компоновка») */
|
||||
/* на десктопе чат отправляет enter — кнопка `>` не нужна: в рамке
|
||||
ввода она остаётся одна, слева (docs/identity/brief.md, «Компоновка»).
|
||||
Остальные формы используют обычные текстовые `.button` */
|
||||
|
||||
.input__send {
|
||||
display: none;
|
||||
|
||||
+21
-2
@@ -167,11 +167,21 @@ function head(view) {
|
||||
const back = el("button", "back back--chat", "назад");
|
||||
back.type = "button";
|
||||
back.addEventListener("click", () => view.ctx.go("#/"));
|
||||
view.title = el("button", "chat-title", titleText(view));
|
||||
view.title = el("button", "chat-title");
|
||||
view.title.type = "button";
|
||||
view.title.addEventListener("click", () => view.ctx.go(view.roomId !== null
|
||||
? `#/room/${view.roomId}/members`
|
||||
: `#/contact/${view.peer}`));
|
||||
if (view.roomId !== null) {
|
||||
// Состав был спрятан за неочевидным нажатием на имя. Одна составная
|
||||
// кнопка сохраняет место в мобильной шапке: имя сокращается, а смысл
|
||||
// действия и число участников остаются видны (ADR-078).
|
||||
view.titleName = el("span", "chat-title__name", titleText(view));
|
||||
view.titleMembers = el("span", "chat-title__members", "· участники");
|
||||
view.title.append(view.titleName, view.titleMembers);
|
||||
} else {
|
||||
view.title.textContent = titleText(view);
|
||||
}
|
||||
bar.append(back, view.title);
|
||||
return bar;
|
||||
}
|
||||
@@ -208,7 +218,16 @@ async function refreshRoom(view, known) {
|
||||
return;
|
||||
}
|
||||
view.name = record?.title || view.roomId;
|
||||
view.title.textContent = titleText(view);
|
||||
const title = titleText(view);
|
||||
view.titleName.textContent = title;
|
||||
const count = record?.hidden === true || !Array.isArray(record?.members)
|
||||
? null
|
||||
: record.members.length;
|
||||
view.titleMembers.textContent = count === null ? "· участники" : `· участники ${count}`;
|
||||
view.title.setAttribute(
|
||||
"aria-label",
|
||||
count === null ? `${title}, открыть участников` : `${title}, открыть участников: ${count}`,
|
||||
);
|
||||
hint(view, `сообщение в #${shortName(view.name)}`);
|
||||
// Скрытая запись комнаты — это room_left или собственный выход:
|
||||
// отправлять больше некуда, и сервер ответил бы not_member.
|
||||
|
||||
@@ -32,7 +32,7 @@ export function renderMembers(root, ctx, roomId) {
|
||||
const people = el("section", "block block--first");
|
||||
view.list = el("ul", "members");
|
||||
view.note = message();
|
||||
people.append(view.list, add(view), view.note);
|
||||
people.append(el("h2", "section", "участники"), view.list, add(view), view.note);
|
||||
|
||||
body.append(view.warn, people, exit(view));
|
||||
root.append(body);
|
||||
|
||||
+113
-48
@@ -1,5 +1,5 @@
|
||||
// Новый чат — docs/ui.md, «Новый чат». Две строки ввода: `@ник` открывает
|
||||
// личный чат, `#имя комнаты` заводит комнату.
|
||||
// Новый чат — docs/ui.md, «Новый чат». Это выбор одного действия:
|
||||
// личный чат по нику или новая приватная комната (ADR-078).
|
||||
|
||||
import * as sync from "../sync.js";
|
||||
import { el, message, setError, setNote } from "./dom.js";
|
||||
@@ -8,99 +8,164 @@ import { el, message, setError, setNote } from "./dom.js";
|
||||
// единицы UTF-16, а сервер — руны: за предел это не выпустит.
|
||||
const ROOM_NAME_MAX = 64;
|
||||
|
||||
const MODES = [
|
||||
["dm", "личный чат"],
|
||||
["room", "новая комната"],
|
||||
];
|
||||
|
||||
export function renderNew(root, ctx) {
|
||||
root.append(head(ctx));
|
||||
|
||||
const body = el("div", "body");
|
||||
// Место под ошибку одно на оба поля: строка состояния у экрана одна
|
||||
// (ADR-028).
|
||||
const body = el("div", "body new");
|
||||
const dm = direct(ctx);
|
||||
const room = roomForm(ctx);
|
||||
const panels = { dm, room };
|
||||
let mode = "dm";
|
||||
|
||||
const tabs = el("div", "tabs new__tabs");
|
||||
tabs.setAttribute("role", "group");
|
||||
tabs.setAttribute("aria-label", "что открыть");
|
||||
|
||||
const buttons = new Map();
|
||||
const select = (next, focus = false) => {
|
||||
mode = next;
|
||||
for (const [name, panel] of Object.entries(panels)) {
|
||||
const on = name === mode;
|
||||
panel.root.hidden = !on;
|
||||
const tab = buttons.get(name);
|
||||
tab.classList.toggle("is-on", on);
|
||||
tab.setAttribute("aria-pressed", String(on));
|
||||
}
|
||||
if (focus) {
|
||||
panels[mode].field.focus();
|
||||
}
|
||||
};
|
||||
|
||||
MODES.forEach(([name, text], index) => {
|
||||
if (index > 0) {
|
||||
const sep = el("span", "tabs__sep", "/");
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
tabs.append(sep);
|
||||
}
|
||||
const tab = el("button", "tab", text);
|
||||
tab.type = "button";
|
||||
tab.addEventListener("click", () => select(name, true));
|
||||
buttons.set(name, tab);
|
||||
tabs.append(tab);
|
||||
});
|
||||
|
||||
body.append(tabs, dm.root, room.root);
|
||||
root.append(body);
|
||||
select(mode);
|
||||
}
|
||||
|
||||
function direct(ctx) {
|
||||
const panel = section("напишите человеку по нику.");
|
||||
const input = field("ник человека", "@ник");
|
||||
const go = el("button", "button", "написать");
|
||||
go.type = "submit";
|
||||
const note = message();
|
||||
panel.form.append(input.wrap, go, note);
|
||||
|
||||
const dm = row("@ник");
|
||||
const room = row("#имя комнаты");
|
||||
room.field.maxLength = ROOM_NAME_MAX;
|
||||
|
||||
dm.form.addEventListener("submit", async (event) => {
|
||||
panel.form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (dm.go.disabled) {
|
||||
if (go.disabled) {
|
||||
return;
|
||||
}
|
||||
setNote(note, "");
|
||||
// Ник вводят как в списке: с «@» или без. Регистр не хранится —
|
||||
// ники строчные (ADR-019).
|
||||
const nick = dm.field.value.trim().replace(/^@/, "").toLowerCase();
|
||||
const nick = input.field.value.trim().replace(/^@/, "").toLowerCase();
|
||||
if (nick === "") {
|
||||
dm.field.focus();
|
||||
input.field.focus();
|
||||
return;
|
||||
}
|
||||
dm.field.value = nick;
|
||||
dm.go.disabled = true;
|
||||
input.field.value = nick;
|
||||
go.disabled = true;
|
||||
try {
|
||||
await sync.openDm(nick);
|
||||
ctx.go(`#/dm/${nick}`);
|
||||
} catch (err) {
|
||||
setError(note, ctx.errorText(err));
|
||||
dm.field.focus();
|
||||
input.field.focus();
|
||||
} finally {
|
||||
dm.go.disabled = false;
|
||||
go.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
room.form.addEventListener("submit", async (event) => {
|
||||
return { ...panel, field: input.field };
|
||||
}
|
||||
|
||||
function roomForm(ctx) {
|
||||
const panel = section(
|
||||
"создайте новую приватную комнату. сначала в ней будете только вы; участников добавите следующим шагом.",
|
||||
);
|
||||
panel.copy.append(el(
|
||||
"span",
|
||||
"new__aside",
|
||||
"если вас добавят в чужую комнату, она появится в списке сама.",
|
||||
));
|
||||
const input = field("название новой комнаты", "#название");
|
||||
input.field.maxLength = ROOM_NAME_MAX;
|
||||
const go = el("button", "button", "создать комнату");
|
||||
go.type = "submit";
|
||||
const note = message();
|
||||
panel.form.append(input.wrap, go, note);
|
||||
|
||||
panel.form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (room.go.disabled) {
|
||||
if (go.disabled) {
|
||||
return;
|
||||
}
|
||||
setNote(note, "");
|
||||
// Имя вводят как в списке: с «#» или без. Регистр имени комнаты
|
||||
// сохраняется — это её название, а не идентификатор.
|
||||
const name = room.field.value.trim().replace(/^#/, "").trim();
|
||||
const name = input.field.value.trim().replace(/^#/, "").trim();
|
||||
if (name === "") {
|
||||
room.field.focus();
|
||||
input.field.focus();
|
||||
return;
|
||||
}
|
||||
room.go.disabled = true;
|
||||
go.disabled = true;
|
||||
try {
|
||||
const chatId = await sync.createRoom(name);
|
||||
if (chatId === null) {
|
||||
room.field.focus();
|
||||
input.field.focus();
|
||||
return;
|
||||
}
|
||||
ctx.go(`#/room/${sync.roomIdOf(chatId)}`);
|
||||
// Новая комната начинается с одного владельца. Первый полезный
|
||||
// экран — её состав: там сразу видны владелец и добавление людей.
|
||||
ctx.go(`#/room/${sync.roomIdOf(chatId)}/members`);
|
||||
} catch (err) {
|
||||
setError(note, ctx.errorText(err));
|
||||
room.field.focus();
|
||||
input.field.focus();
|
||||
} finally {
|
||||
room.go.disabled = false;
|
||||
go.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
body.append(dm.form, room.form, note);
|
||||
root.append(body);
|
||||
dm.field.focus();
|
||||
return { ...panel, field: input.field };
|
||||
}
|
||||
|
||||
// row — строка ввода в стиле чата: рамка 1 px ink, слева «>» цветом mark
|
||||
// (docs/identity/brief.md, «Компоновка»).
|
||||
function row(placeholder) {
|
||||
const form = el("form", "form form--row");
|
||||
function section(text) {
|
||||
const root = el("section", "new__panel");
|
||||
const copy = el("p", "new__copy", text);
|
||||
const form = el("form", "form");
|
||||
form.noValidate = true;
|
||||
root.append(copy, form);
|
||||
return { root, copy, form };
|
||||
}
|
||||
|
||||
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 field(label, placeholder) {
|
||||
const wrap = el("label", "field");
|
||||
wrap.append(el("span", null, label));
|
||||
const input = el("input");
|
||||
input.type = "text";
|
||||
input.placeholder = placeholder;
|
||||
input.autocapitalize = "off";
|
||||
input.autocomplete = "off";
|
||||
input.spellcheck = false;
|
||||
wrap.append(input);
|
||||
return { wrap, field: input };
|
||||
}
|
||||
|
||||
function head(ctx) {
|
||||
|
||||
Reference in New Issue
Block a user