>сообщение в #generalenter — отправить
@@ -125,7 +114,7 @@
18 авг
marta 11:52
выкатила статику на bare.xmatic.team, кэш чистится сам
вес страницы — 14 кб
lev 11:58
один html, один css, ноль js до первого сообщения. красиво
-
kir 12:03
это и есть план. если фича требует бандлер — фича не нужна
+
kir 12:03
это и есть план. если фича требует бандлер — фича не нужна
новые
marta 12:41
кто-то с hn спрашивает, где мобильное приложение
lev 12:42
ответил: браузер и есть приложение
diff --git a/docs/open-questions.md b/docs/open-questions.md
index 8b3e929..e74f6e3 100644
--- a/docs/open-questions.md
+++ b/docs/open-questions.md
@@ -5,5 +5,6 @@
- Серверный «перец» для ключевого блоба: дополнительное шифрование блоба серверным ключом, хранящимся вне базы. Плюс: дамп базы сам по себе перестаёт быть материалом для оффлайн-перебора. Минус: не защищает от оператора; потеря серверного ключа — невозможность входа с новых устройств для всех. Решение отложено.
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
+- Выравнивание сообщений по стороне: свои — вправо, чужие — влево, как в обычных мессенджерах. Сейчас лента — общий список с колонкой автора (`docs/ui.md`, «Чат»; `docs/identity/brief.md`, «Компоновка»: сетка `132px 1fr` на десктопе, автор над группой на мобильном) — сторона у сообщения не задана нигде. Пользователям в тесте медленно читать, кто есть кто. Смена — это пересмотр принятого в ADR-024 макета ленты, нужен новый ADR, если решение — делать. Ограничение айдентики: без пузырей, скруглений и теней (`identity/brief.md`) — выравнивание, не bubble-чат.
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей.
diff --git a/docs/ui.md b/docs/ui.md
index e8f7e15..f0019d2 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -32,7 +32,7 @@
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн».
-Лента: десктоп — сетка «автор 132 px + текст», подряд идущие сообщения одного автора — без повтора автора; мобильный — автор над группой. Свой ник в колонке автора — цветом `mark`. Разделители дат — линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
+Лента: поток блоков сверху вниз, одинаковый на десктопе и мобильном; подряд идущие сообщения одного автора — без повтора автора над группой. Свои сообщения выровнены по правому краю ленты, чужие — по левому (ADR-065); свой ник в колонке автора при этом остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока выровнен по левому краю независимо от стороны. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
diff --git a/web/app.css b/web/app.css
index 48ca5d0..d37e0d9 100644
--- a/web/app.css
+++ b/web/app.css
@@ -650,19 +650,27 @@ input[type="password"] {
overflow-y: auto;
}
-.grid {
+.feed-list {
margin-top: auto;
display: flex;
flex-direction: column;
+ align-items: flex-start;
font-size: 14px;
- line-height: 1.5;
+ line-height: 1.55;
}
-/* мобильный: автор над группой сообщений */
+.feed-list > :first-child {
+ margin-top: 0;
+}
+
+/* строка сообщения: автор над текстом, блок целиком к своему краю —
+ свои вправо, чужие влево (ADR-065). Ширина блока ограничена, иначе
+ выравнивать нечего — текст в один абзац занял бы всю ленту. */
.line {
display: flex;
flex-direction: column;
+ max-width: min(640px, 85%);
gap: 4px;
margin-top: 4px;
}
@@ -671,8 +679,9 @@ input[type="password"] {
margin-top: 14px;
}
-.grid > :first-child {
- margin-top: 0;
+.line--me {
+ align-self: flex-end;
+ align-items: flex-end;
}
.author {
@@ -684,6 +693,10 @@ input[type="password"] {
overflow-wrap: anywhere;
}
+.author:empty {
+ display: none;
+}
+
.author--me {
color: var(--mark);
}
@@ -734,11 +747,14 @@ input[type="password"] {
cursor: pointer;
}
-/* разделители: дата — линией line, «новые» — линией mark */
+/* разделители: дата — линией line, «новые» — линией mark; во всю ширину
+ ленты, а не по ширине сообщения (feed-list выравнивает по краю только
+ .line, ADR-065) */
.divider {
display: flex;
align-items: center;
+ align-self: stretch;
gap: 14px;
margin: 14px 0;
color: var(--stone);
@@ -908,39 +924,10 @@ input[type="password"] {
display: none;
}
- /* сетка «автор 132 px + текст»: строка сообщения раскрывается
- в две ячейки сетки, поэтому display: contents */
-
.feed {
padding: 28px 32px;
}
- .grid {
- display: grid;
- grid-template-columns: 132px 1fr;
- column-gap: 20px;
- row-gap: 6px;
- line-height: 1.55;
- }
-
- .line,
- .line.is-head {
- display: contents;
- margin-top: 0;
- }
-
- .author {
- padding-top: 2px;
- font-size: 12px;
- }
-
- .divider {
- grid-column: 1 / -1;
- margin: 0;
- padding: 16px 0;
- font-size: 11px;
- }
-
.compose {
padding: 0 32px 28px;
}
@@ -992,11 +979,4 @@ input[type="password"] {
.enter {
display: none;
}
-
- /* автор стоит над группой, поэтому пустая ячейка автора не занимает
- места; на десктопе она держит колонку сетки и остаётся */
-
- .author:empty {
- display: none;
- }
}
diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js
index 48aae63..25c96c1 100644
--- a/web/js/ui/chat.js
+++ b/web/js/ui/chat.js
@@ -79,7 +79,7 @@ export function renderChat(root, ctx, chatId) {
root.append(head(view));
view.feed = el("div", "feed");
- view.body = el("div", "grid");
+ view.body = el("div", "feed-list");
view.body.setAttribute("aria-live", "polite");
view.feed.append(view.body);
// Прокрутка к верхнему краю берёт следующую страницу (ADR-053).
@@ -197,9 +197,16 @@ async function refreshRoom(view, known) {
// composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>»
// цветом mark. Enter отправляет только на десктопе; на мобильном он делает
// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»).
+//
+// div, не form: поле формы на iOS Safari/Chrome поднимает над клавиатурой
+// системную панель навигации между полями («‹ ›» и «готово») — лишние
+// ~50 px ради одного поля, которому переходить некуда. role="form" держит
+// ту же семантику для скринридера (docs/ui.md, «Доступность»), без form-а;
+// autocomplete="off" на поле — по той же причине, на случай если панель
+// зависит ещё и от него.
function composer(view) {
- const form = el("form", "compose");
- form.noValidate = true;
+ const form = el("div", "compose");
+ form.setAttribute("role", "form");
view.bar = el("p", "bar");
view.bar.hidden = true;
@@ -213,12 +220,14 @@ function composer(view) {
view.field.rows = 1;
view.field.placeholder = "сообщение";
view.field.maxLength = view.limit;
+ view.field.autocomplete = "off";
view.counter = el("span", "counter");
view.counter.hidden = true;
view.send = el("button", "input__send", ">");
- view.send.type = "submit";
+ view.send.type = "button";
+ view.send.addEventListener("click", () => submit(view));
row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send);
form.append(view.bar, row);
@@ -234,10 +243,6 @@ function composer(view) {
event.preventDefault();
submit(view);
});
- form.addEventListener("submit", (event) => {
- event.preventDefault();
- submit(view);
- });
return form;
}
@@ -519,12 +524,25 @@ function line(view, record, previous, parent = view.body) {
}
// Подряд идущие сообщения одного автора — без повтора автора.
const first = marks.length > 0 || !previous || previous.from !== record.from;
- const node = el("div", first ? "line is-head" : "line");
+ const node = el("div", lineClass(record, view.me, first));
node.append(author(view, record, first), text(view, record));
parent.append(...marks, node);
view.nodes.set(record.id, { node, marks });
}
+// lineClass — своя сторона ленты: свои сообщения вправо, чужие влево
+// (ADR-065), классом на самой строке — выравнивает feed-list.
+function lineClass(record, me, first) {
+ const classes = ["line"];
+ if (record.from === me) {
+ classes.push("line--me");
+ }
+ if (first) {
+ classes.push("is-head");
+ }
+ return classes.join(" ");
+}
+
// redraw обновляет одну строку на месте: автор и группировка от состояния
// сообщения не зависят.
function redraw(view, record) {
diff --git a/web/sw.js b/web/sw.js
index 6b98fa8..1f8f0c0 100644
--- a/web/sw.js
+++ b/web/sw.js
@@ -7,7 +7,7 @@
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
-const VERSION = "v4";
+const VERSION = "v5";
const CACHE = `bare-${VERSION}`;
// Оболочка — всё, из чего клиент поднимается без сети. Список явный: