iOS: запрет масштабирования, версия в подвале, самообновление, строка ввода
Отзыв с iPhone после первого живого прогона. Масштабирование запрещено (ADR-066): viewport как в соседнем sixlines плюс viewport-fit=cover, мета-теги установленного приложения, touch-action против двойного тапа и страховка на JS — гашение жестов iOS, потому что мета-строку Safari вправе проигнорировать. Размеры шрифтов не тронуты: 14 px из брифа остаются, поднимать поля до 16 px владелец отклонил. Цена — доступность для тех, кому зум нужен — записана в ADR и в ui.md честно. Версия в подвале (ADR-065, 067): GET /api/config отдаёт короткую ревизию и время коммита. Именно коммита, а не сборки: штамп компиляции в бинаре means каждая пересборка даёт новый хеш, и обещание ADR-022 «бинарь собирается из тега байт в байт» перестаёт проверяться. Самообновление PWA (ADR-068, 070): в режиме приложения на iPhone перезагрузки нет, и старая версия жила бы вечно. Проверка при возврате в приложение, включение ждущей оболочки, перезагрузка — но не тогда, когда в строке набран текст. Замок от петли сравнивает версию оболочки, а не считает перезагрузки: счётчик отменял саму фичу, долгоживущая вкладка переставала обновляться. Замерено: было 30, 60, 120, 240 секунд между релизами, стало ровно 30. Строка ввода — редактируемый блок (ADR-069): iOS рисует над клавиатурой полосу со стрелками и «готово» для полей формы, и убрать её со страницы нельзя. Для contenteditable не рисует. Режим plaintext-only с проверкой, что он применился, и запасным путём с разбором вставки: невалидное значение атрибута в части браузеров делает блок нередактируемым вовсе. Чтение и запись только через textContent, innerHTML в клиенте по-прежнему нет. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
+126
-15
@@ -25,16 +25,63 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* масштабирования нет (ADR-066). manipulation снимает зум по двойному тапу
|
||||
и задержку 300 мс, которую браузер держит, ожидая второго касания;
|
||||
прокрутку и щипок правило не трогает — щипок запрещён в viewport.
|
||||
Правило стоит на html: двойной тап — жест документа, и браузер берёт
|
||||
пересечение значений всех предков */
|
||||
|
||||
html {
|
||||
touch-action: manipulation;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* виден вырез: viewport-fit=cover пускает страницу под скруглённые углы,
|
||||
под боковой вырез в альбомной ориентации и под верхнюю системную
|
||||
полосу. Полосу над страницей держит мета-строка о стиле полосы
|
||||
(ADR-066), поэтому верхняя вставка обычно нулевая и не делает ничего;
|
||||
там, где она не нулевая, шапка не уезжает под часы */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
background: var(--bone);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
overscroll-behavior: none;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* интерфейс не выделяется: долгое нажатие по шапке, списку и кнопкам
|
||||
вызывает лупу и «копировать», а копировать там нечего. Текст сообщений,
|
||||
отпечатки и поля ввода выделяются как обычно — их именно копируют
|
||||
(ADR-066) */
|
||||
|
||||
.side,
|
||||
.head,
|
||||
.brand,
|
||||
.divider,
|
||||
.input .p,
|
||||
.counter,
|
||||
.enter,
|
||||
button {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* прокрутка внутренних лент не уходит в страницу: отскок и «потянуть
|
||||
для обновления» ощущения приложения не добавляют */
|
||||
|
||||
.list,
|
||||
.body,
|
||||
.feed,
|
||||
.auth {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* #app — колонка ровно в высоту окна: от неё считают высоту экраны,
|
||||
поэтому сайдбар с «ты: @nick» стоит на месте, а прокручивается
|
||||
только содержимое */
|
||||
@@ -360,16 +407,34 @@ input[type="password"] {
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
/* подвал сайдбара: «ты: @nick» и версия с временем коммита (ADR-067).
|
||||
Версия не кнопка: нажимать в ней нечего, поэтому она стоит рядом
|
||||
с кнопкой, а не внутри неё. В сайдбаре 224 px они рядом не помещаются
|
||||
никогда — версия занимает вторую строку и остаётся справа; на мобильном,
|
||||
где сайдбар во всю ширину, обе стоят в одной строке */
|
||||
|
||||
.foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px 8px;
|
||||
margin-top: auto;
|
||||
/* нижняя системная полоса iPhone проходит поверх страницы: подвал
|
||||
отступает от неё, а не прячется под ней (ADR-066) */
|
||||
padding: 6px 20px calc(6px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
margin-top: auto;
|
||||
padding: 16px 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: none;
|
||||
color: var(--mute);
|
||||
font: inherit;
|
||||
@@ -385,6 +450,30 @@ input[type="password"] {
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
/* ник бывает в 32 символа (ADR-019): он обрезается, а версия остаётся
|
||||
целой — читать половину хеша незачем */
|
||||
|
||||
.me__nick {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* версия выделяется: с неё начинается разговор о поломке (ADR-067),
|
||||
а на iPhone скопировать её иначе нечем — ни строки адреса, ни консоли
|
||||
там нет. Общий запрет выделения снят с неё так же, как с текста
|
||||
сообщений и отпечатков (ADR-066) */
|
||||
|
||||
.build {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--stone);
|
||||
font-size: 11px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -766,7 +855,8 @@ input[type="password"] {
|
||||
|
||||
.compose {
|
||||
flex: none;
|
||||
padding: 0 16px 20px;
|
||||
/* то же, что у подвала: строка ввода не уезжает под системную полосу */
|
||||
padding: 0 16px calc(20px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -799,7 +889,10 @@ input[type="password"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 12px 0;
|
||||
/* отступ назван: высота блока считается вместе с ним (box-sizing —
|
||||
border-box), и предел роста строки сообщения меряется от него */
|
||||
--field-pad: 12px;
|
||||
padding: var(--field-pad) 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
@@ -809,15 +902,26 @@ input[type="password"] {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* строка ввода перебивает общее правило для input: рамка здесь одна,
|
||||
у всей строки */
|
||||
/* строка сообщения — редактируемый блок, а не поле формы (ADR-069):
|
||||
растёт под текст сама, дальше семи строк — прокрутка. Переносы строк
|
||||
в ней настоящие символы, поэтому pre-wrap; длинное слово рвётся,
|
||||
иначе строка ввода уезжает вбок */
|
||||
|
||||
.input textarea.input__field {
|
||||
resize: none;
|
||||
.input .input__field--text {
|
||||
overflow-y: auto;
|
||||
max-height: 7em;
|
||||
/* растёт под текст там, где браузер это умеет; где нет — прокрутка */
|
||||
field-sizing: content;
|
||||
/* семь строк — это семь высот строки плюс вертикальные отступы:
|
||||
без них предел вышел бы вдвое ниже обещанного */
|
||||
max-height: calc(7 * 1.5em + 2 * var(--field-pad));
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* подсказка пустой строки: placeholder у блока не бывает, текст приходит
|
||||
атрибутом data-hint (ADR-069) */
|
||||
|
||||
.input .input__field--text:empty::before {
|
||||
content: attr(data-hint);
|
||||
color: var(--stone);
|
||||
}
|
||||
|
||||
.input .input__field::placeholder {
|
||||
@@ -942,7 +1046,7 @@ input[type="password"] {
|
||||
}
|
||||
|
||||
.compose {
|
||||
padding: 0 32px 28px;
|
||||
padding: 0 32px calc(28px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -951,7 +1055,7 @@ input[type="password"] {
|
||||
}
|
||||
|
||||
.input .input__field {
|
||||
padding: 13px 0;
|
||||
--field-pad: 13px;
|
||||
}
|
||||
|
||||
/* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в
|
||||
@@ -993,6 +1097,13 @@ input[type="password"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* строка сообщения — тоже цель нажатия не меньше 44 px (docs/ui.md,
|
||||
«Доступность»): своей высоты у блока нет, её задаёт содержимое */
|
||||
|
||||
.input .input__field--text {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* автор стоит над группой, поэтому пустая ячейка автора не занимает
|
||||
места; на десктопе она держит колонку сетки и остаётся */
|
||||
|
||||
|
||||
+5
-1
@@ -2,8 +2,12 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#F7F5F0">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="bare">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<title>bare</title>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
@@ -32,6 +32,7 @@ import { renderMembers } from "./ui/members.js";
|
||||
import { renderNew } from "./ui/new.js";
|
||||
import { renderSettings } from "./ui/settings.js";
|
||||
import { frame } from "./ui/shell.js";
|
||||
import { lock } from "./zoom.js";
|
||||
|
||||
// Минимальная длина пароля — ADR-013.
|
||||
const MIN_PASSWORD = 12;
|
||||
@@ -481,10 +482,18 @@ function errorText(err) {
|
||||
// --- старт -------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
// Масштабирования нет: интерфейс ведёт себя как приложение, а не как
|
||||
// страница (ADR-066). Строке viewport Safari верит не всегда, поэтому
|
||||
// страховка ставится до первого экрана.
|
||||
lock();
|
||||
db.persist();
|
||||
// Service worker ставится с первой секунды: кэш оболочки нужен и до
|
||||
// входа, а пуши приходят в него же (ADR-023). Отказ ничего не ломает.
|
||||
pwa.register();
|
||||
// Новая версия оболочки включается сама и перезагружает страницу:
|
||||
// в установленном на «Домой» приложении перезагрузить её нечем
|
||||
// (ADR-068).
|
||||
pwa.watch();
|
||||
// Первое успешно отправленное сообщение за всю историю устройства —
|
||||
// единственный повод спросить разрешение на уведомления (ADR-011);
|
||||
// «один раз» считает pwa.js.
|
||||
|
||||
+348
-2
@@ -1,5 +1,5 @@
|
||||
// PWA: service worker, подписка на пуши и установка приложения
|
||||
// (ADR-011, ADR-023, ADR-046).
|
||||
// PWA: service worker, самообновление оболочки, подписка на пуши
|
||||
// и установка приложения (ADR-011, ADR-023, ADR-046, ADR-068).
|
||||
//
|
||||
// Экраны спрашивают отсюда состояние и сюда же отдают действия; в
|
||||
// pushManager, IndexedDB и сеть они не ходят — как и с чатом, это делает
|
||||
@@ -17,6 +17,48 @@ import { deviceId } from "./sync.js";
|
||||
|
||||
const WORKER = "/sw.js";
|
||||
|
||||
// Слово, по которому service worker включает установленную версию
|
||||
// (ADR-068). Решение принимает страница, а не воркер.
|
||||
const SKIP = "skip-waiting";
|
||||
|
||||
// Слово, которым страница спрашивает воркера, какую версию оболочки он
|
||||
// держит. Этим версии и различаются: новый выпуск меняет её, а тот же файл
|
||||
// воркера, отданный сервером заново, — нет (ADR-070).
|
||||
const ASK = "version";
|
||||
|
||||
// Сколько ждать ответа о версии. Отвечают сразу — вопрос стоит одного
|
||||
// сообщения; молчит воркер прежнего выпуска, который такого вопроса
|
||||
// не знает, и тогда версия остаётся неизвестной.
|
||||
const ASK_WAIT = 3 * 1000;
|
||||
|
||||
// Как часто спрашивать сервер об обновлении: при запуске и при каждом
|
||||
// возвращении в приложение, но не чаще раза в минуту. Проверка — один
|
||||
// условный запрос за /sw.js, и дёргать сеть на каждое переключение
|
||||
// приложений незачем (ADR-068).
|
||||
const CHECK_EVERY = 60 * 1000;
|
||||
|
||||
// Сколько ждать между двумя перезагрузками ради обновления в одной
|
||||
// вкладке. Пауза постоянная и не растёт: петлю закрывает сверка версии
|
||||
// оболочки (ADR-070), а это предел частоты — на случай сервера, который
|
||||
// отдаёт разные версии на каждый запрос. Отметка живёт в sessionStorage:
|
||||
// она переживает перезагрузку и умирает вместе с вкладкой.
|
||||
const RELOAD_KEY = "bare-updated-at";
|
||||
const RELOAD_APART = 30 * 1000;
|
||||
|
||||
// Как часто возвращаться к отложенному, пока в полях набранное. Событие
|
||||
// input ловит набор, но опустеть поле умеет и молча: отправленное
|
||||
// сообщение чистит строку присваиванием, а уход с экрана уносит её
|
||||
// из документа вместе с текстом — событий не случается ни там, ни там.
|
||||
// Обход полей раз в полминуты ничего не стоит и заводится только тогда,
|
||||
// когда обновление уже ждёт (ADR-072).
|
||||
const DRAFT_AGAIN = 30 * 1000;
|
||||
|
||||
// Поля, куда набирают текст. Проверяется то, что считается набранным,
|
||||
// а не список исключений: у скрытого поля, переключателя и кнопки
|
||||
// значение непусто по определению, и одно такое поле молча запретило бы
|
||||
// обновление навсегда (ADR-068).
|
||||
const TYPED = new Set(["text", "password", "search", "email", "url", "tel", "number"]);
|
||||
|
||||
// iOS: пуши работают только у приложения, установленного на экран «Домой»
|
||||
// (ADR-011). Признак — docs/ui.md, «Баннер установки»: iPhone|iPad
|
||||
// и navigator.standalone !== true.
|
||||
@@ -40,6 +82,40 @@ const state = {
|
||||
// показывает «запрещены в браузере» (ADR-046). Флаг живёт во вкладке:
|
||||
// перезагрузка пробует снова — причина могла уйти.
|
||||
refused: false,
|
||||
// Обновление (ADR-068). controlled — управляет ли страницей service
|
||||
// worker. У такой страницы смену контроллёра разбирает fresh: другая
|
||||
// версия оболочки стоит перезагрузки, та же — нет (ADR-070). У первого
|
||||
// захода контроллёр появляется и без обновления: его приход сменой
|
||||
// не считается, но запоминается — дальше страница управляемая.
|
||||
controlled: "serviceWorker" in navigator && navigator.serviceWorker.controller !== null,
|
||||
// Новую версию попросили включиться мы сами. Тогда смена контроллёра —
|
||||
// ответ на нашу просьбу, и перезагружаться надо, даже если при загрузке
|
||||
// воркера у страницы ещё не было: в первый заход он ставится тут же,
|
||||
// а обновление может приехать в тот же сеанс. Имя своё: у вопроса про
|
||||
// уведомления рядом живёт свой флаг, и один на двоих означал бы, что
|
||||
// обновление закрывает вопрос про уведомления, а вопрос — обновление.
|
||||
requested: false,
|
||||
// Оболочка сменилась, а перезагрузка отложена набранным текстом.
|
||||
// Просить включения могла и соседняя вкладка с пустыми полями
|
||||
// (ADR-035): контроллёр меняется у всех сразу, а платить за это
|
||||
// черновиком этой вкладки не должен никто. Перезагрузимся, когда
|
||||
// терять станет нечего.
|
||||
changed: false,
|
||||
// Перезагрузка началась: controllerchange приходит один раз, но цена
|
||||
// ошибки — бесконечный цикл.
|
||||
reloading: false,
|
||||
// Новая версия установлена и ждёт включения. Ждать её заставляет
|
||||
// набранный текст: перезагрузка унесла бы его.
|
||||
waiting: null,
|
||||
// Когда последний раз спрашивали сервер об обновлении.
|
||||
checked: 0,
|
||||
// Версия оболочки, код которой исполняет эта страница, — обещание
|
||||
// ответа воркера. С ней сравнивается версия включившейся: совпали —
|
||||
// перезагружаться незачем, оболочка та же (ADR-070).
|
||||
shell: Promise.resolve(null),
|
||||
// Отложенная до конца паузы попытка: другого повода вернуться к ней
|
||||
// может и не быть.
|
||||
timer: 0,
|
||||
};
|
||||
|
||||
// Приглашение установки ловится с первой секунды: браузер показывает его
|
||||
@@ -79,6 +155,276 @@ async function ready() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- обновление ---------------------------------------------------------
|
||||
|
||||
// watch включает самообновление: проверку при запуске и при возвращении
|
||||
// в приложение, включение установленной версии и перезагрузку страницы
|
||||
// (ADR-068). Установленное на «Домой» приложение обновить иначе нечем:
|
||||
// ни строки адреса, ни кнопки перезагрузки в нём нет.
|
||||
export function watch() {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
// Версия оболочки, код которой исполняет эта страница. Спрашивается
|
||||
// сразу: воркер, который её держит, скоро сменится, а сравнивать надо
|
||||
// с тем, чей код уже загружен (ADR-070).
|
||||
state.shell = ask(navigator.serviceWorker.controller);
|
||||
// Контроллёр сменился — работает другая версия оболочки, а страница
|
||||
// исполняет прежнюю. Перезагрузка одна за жизнь страницы.
|
||||
//
|
||||
// Первая установка её не считается: там контроллёр появляется впервые
|
||||
// (clients.claim), а страница и так свежая. Эту смену замок съедает
|
||||
// и запоминает — вместе с версией, которую новый контроллёр держит:
|
||||
// она и есть версия загруженной оболочки.
|
||||
//
|
||||
// Перезагружаемся не отсюда: включить новую версию могла соседняя
|
||||
// вкладка, у которой поля пусты, а у этой в них набранное. Отмечаем
|
||||
// и ждём — apply перезагрузит, когда терять станет нечего.
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
if (state.reloading) {
|
||||
return;
|
||||
}
|
||||
if (!state.requested && !state.controlled) {
|
||||
state.controlled = true;
|
||||
state.shell = ask(navigator.serviceWorker.controller);
|
||||
return;
|
||||
}
|
||||
fresh();
|
||||
});
|
||||
// Возвращение в приложение — единственный момент, когда о нём вспоминают
|
||||
// на телефоне: вкладка не закрывается неделями. Событие приходит
|
||||
// документу, ему и слушаем.
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
check();
|
||||
}
|
||||
});
|
||||
// Набранное стёрли — терять стало нечего, и отложенное обновление
|
||||
// можно включать.
|
||||
addEventListener("input", () => apply(), true);
|
||||
register().then((registration) => {
|
||||
if (registration === null) {
|
||||
return;
|
||||
}
|
||||
registration.addEventListener("updatefound", () => track(registration.installing));
|
||||
// Версия, установившаяся в прошлый заход и не дождавшаяся своей
|
||||
// очереди: вкладку закрыли раньше.
|
||||
track(registration.waiting);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
// check спрашивает сервер, нет ли новой версии. Сети нет — молча:
|
||||
// офлайн не сбой, спросим при следующем возвращении (ADR-068).
|
||||
async function check() {
|
||||
if (Date.now() - state.checked < CHECK_EVERY) {
|
||||
// Спрашивать рано, но отложенное обновление могло дождаться своего
|
||||
// часа: набранное стёрли или отправили.
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
state.checked = Date.now();
|
||||
const registration = await register();
|
||||
if (registration === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await registration.update();
|
||||
} catch {
|
||||
// Сервер молчит или сети нет.
|
||||
}
|
||||
// Ждущая версия берётся и отсюда, а не только из updatefound: событие
|
||||
// случается один раз, а включить её могло не выйти — страница была
|
||||
// занята набранным текстом или сообщение до воркера не дошло.
|
||||
track(registration.waiting);
|
||||
apply();
|
||||
}
|
||||
|
||||
// track следит за устанавливающейся версией: как только она встала
|
||||
// и ждёт, её можно включать.
|
||||
function track(worker) {
|
||||
if (!worker || worker === state.waiting) {
|
||||
return;
|
||||
}
|
||||
const look = () => {
|
||||
if (worker.state === "installed") {
|
||||
state.waiting = worker;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
worker.addEventListener("statechange", look);
|
||||
look();
|
||||
}
|
||||
|
||||
// fresh разбирает смену контроллёра: включилась другая версия оболочки
|
||||
// или тот же файл воркера, отданный сервером заново. Перезагрузка нужна
|
||||
// только в первом случае: во втором она вернула бы ту же самую страницу,
|
||||
// и следующий запрос за sw.js начал бы всё сначала — это и есть петля
|
||||
// (ADR-070).
|
||||
//
|
||||
// Неизвестная версия считается другой: молчит воркер прежнего выпуска,
|
||||
// и обновление до выпуска, который отвечает, важнее. Повторяться этому
|
||||
// не с чего — после перезагрузки отвечают оба.
|
||||
async function fresh() {
|
||||
const [was, now] = await Promise.all([state.shell, ask(navigator.serviceWorker.controller)]);
|
||||
if (state.reloading) {
|
||||
return;
|
||||
}
|
||||
if (was !== null && now !== null && was === now) {
|
||||
return;
|
||||
}
|
||||
state.changed = true;
|
||||
apply();
|
||||
}
|
||||
|
||||
// apply двигает обновление вперёд — если сейчас есть чем платить.
|
||||
// Набранное откладывает и просьбу включиться, и перезагрузку: и то,
|
||||
// и другое кончается новой оболочкой, а она уносит поля (ADR-068).
|
||||
//
|
||||
// Первая установка до просьбы не доходит: страницей ещё никто
|
||||
// не управляет, включать нечего, а воркер и так активируется сам.
|
||||
// Через «installed» он при этом проходит — без проверки контроллёра
|
||||
// первый же запуск перезагружал бы себя сам.
|
||||
function apply() {
|
||||
if (state.reloading) {
|
||||
return;
|
||||
}
|
||||
if (typed()) {
|
||||
// Набранное откладывает обновление, а поводов вернуться к нему мало:
|
||||
// событие input случается при наборе, но не при отправке сообщения
|
||||
// и не при уходе с экрана — там поле пустеет и пропадает молча.
|
||||
// Поэтому ждём ещё и по таймеру (ADR-072).
|
||||
if (state.changed || state.waiting !== null) {
|
||||
later(DRAFT_AGAIN);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.changed) {
|
||||
// Оболочка уже сменилась — просить больше нечего, осталось
|
||||
// перезагрузиться. Слишком часто подряд не перезагружаемся:
|
||||
// пауза кончится, и apply вернётся сюда сам.
|
||||
const wait = pause();
|
||||
if (wait > 0) {
|
||||
later(wait);
|
||||
return;
|
||||
}
|
||||
state.reloading = true;
|
||||
markReload();
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
const worker = state.waiting;
|
||||
if (worker === null || navigator.serviceWorker.controller === null) {
|
||||
return;
|
||||
}
|
||||
state.waiting = null;
|
||||
state.requested = true;
|
||||
worker.postMessage(SKIP);
|
||||
}
|
||||
|
||||
// typed — есть ли на экране набранное. Проверка общая на весь документ,
|
||||
// а не на строку ввода: пароль в форме входа теряется от перезагрузки
|
||||
// так же, как черновик сообщения. Пустое поле под курсором ничего
|
||||
// не стоит: оно и после перезагрузки пустое.
|
||||
//
|
||||
// Набранным считается только то, куда набирают: textarea и поля из
|
||||
// TYPED. Чекбокс, файл, скрытое поле и кнопка непусты сами по себе,
|
||||
// а обновление они бы запретили насовсем.
|
||||
//
|
||||
// Строка сообщения в чате — редактируемый блок, а не поле формы
|
||||
// (ADR-069): value у него нет, набранное лежит в textContent. Считается
|
||||
// и закрытый блок: ввод бывает заблокирован предупреждением о ключе,
|
||||
// а недописанное в нём остаётся.
|
||||
function typed() {
|
||||
for (const node of document.querySelectorAll("input, textarea")) {
|
||||
if (node.value === "") {
|
||||
continue;
|
||||
}
|
||||
if (node.tagName === "TEXTAREA" || TYPED.has(node.type)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const node of document.querySelectorAll("[contenteditable]")) {
|
||||
if (node.textContent !== "") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ask спрашивает у воркера версию оболочки, которую он держит. Ответ идёт
|
||||
// своим каналом: вопросов бывает два подряд, а перепутать ответы нельзя.
|
||||
//
|
||||
// Молчание — не сбой: так отвечает воркер прежнего выпуска, который такого
|
||||
// вопроса не знает. Тогда версия неизвестна (null), и решение принимается
|
||||
// в пользу обновления (ADR-070).
|
||||
function ask(worker) {
|
||||
return new Promise((done) => {
|
||||
if (!worker) {
|
||||
done(null);
|
||||
return;
|
||||
}
|
||||
const channel = new MessageChannel();
|
||||
const timer = setTimeout(() => {
|
||||
channel.port1.close();
|
||||
done(null);
|
||||
}, ASK_WAIT);
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timer);
|
||||
channel.port1.close();
|
||||
done(typeof event.data === "string" && event.data !== "" ? event.data : null);
|
||||
};
|
||||
try {
|
||||
worker.postMessage(ASK, [channel.port2]);
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
done(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// pause — сколько ещё ждать до перезагрузки ради обновления. Петлю она
|
||||
// не закрывает — это делает сверка версии оболочки (ADR-070); здесь только
|
||||
// предел частоты на случай сервера, который отдаёт разные версии на каждый
|
||||
// запрос. Отметки нет — значит, и повода ждать нет.
|
||||
function pause() {
|
||||
const at = Number(session(RELOAD_KEY));
|
||||
if (!Number.isFinite(at) || at <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const left = RELOAD_APART - (Date.now() - at);
|
||||
return left > 0 ? Math.min(left, RELOAD_APART) : 0;
|
||||
}
|
||||
|
||||
// later возвращается к отложенному сам: другого повода может и не быть —
|
||||
// вкладка открыта, обновление ждёт, а ждать его заставляет то пауза между
|
||||
// перезагрузками (ADR-070), то набранное в полях (ADR-072).
|
||||
function later(ms) {
|
||||
if (state.timer !== 0) {
|
||||
return;
|
||||
}
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = 0;
|
||||
apply();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function markReload() {
|
||||
try {
|
||||
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// Хранилище закрыто политикой браузера: обойдёмся без страховки.
|
||||
}
|
||||
}
|
||||
|
||||
function session(key) {
|
||||
try {
|
||||
return sessionStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- уведомления --------------------------------------------------------
|
||||
|
||||
// supported — есть ли в браузере то, из чего складывается пуш. iOS вне
|
||||
|
||||
+303
-21
@@ -49,6 +49,10 @@ export function renderChat(root, ctx, chatId) {
|
||||
roomId: sync.roomIdOf(chatId),
|
||||
limit: ctx.config?.maxMessageChars ?? LIMIT,
|
||||
alive: true,
|
||||
// Каким значением открыт редактируемый блок строки ввода:
|
||||
// "plaintext-only" или "true" (ADR-069). От него зависит, разбирает ли
|
||||
// браузер вставку и перенос строки сам или это делаем мы.
|
||||
edit: "true",
|
||||
// Имя комнаты; до чтения записи чата вместо него идентификатор,
|
||||
// как в db.blankChat.
|
||||
name: sync.roomIdOf(chatId),
|
||||
@@ -185,18 +189,22 @@ async function refreshRoom(view, known) {
|
||||
}
|
||||
view.name = record?.title || view.roomId;
|
||||
view.title.textContent = titleText(view);
|
||||
view.field.placeholder = `сообщение в #${shortName(view.name)}`;
|
||||
hint(view, `сообщение в #${shortName(view.name)}`);
|
||||
// Скрытая запись комнаты — это room_left или собственный выход:
|
||||
// отправлять больше некуда, и сервер ответил бы not_member.
|
||||
view.gone = record?.hidden === true;
|
||||
view.field.disabled = view.gone;
|
||||
view.send.disabled = view.gone;
|
||||
allow(view, !view.gone);
|
||||
paintBar(view);
|
||||
}
|
||||
|
||||
// composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>»
|
||||
// цветом mark. Enter отправляет только на десктопе; на мобильном он делает
|
||||
// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»).
|
||||
//
|
||||
// Строка сообщения — редактируемый блок, а не поле формы (ADR-069): над
|
||||
// клавиатурой iOS рисует полосу помощника форм, и бывает она только
|
||||
// у input и textarea. Со страницы её не убрать, а в чате она занимает место
|
||||
// и ничего не делает: поле на экране одно, переходить стрелками некуда.
|
||||
function composer(view) {
|
||||
const form = el("form", "compose");
|
||||
form.noValidate = true;
|
||||
@@ -209,10 +217,22 @@ function composer(view) {
|
||||
const prompt = el("span", "p", ">");
|
||||
prompt.setAttribute("aria-hidden", "true");
|
||||
|
||||
view.field = el("textarea", "input__field");
|
||||
view.field.rows = 1;
|
||||
view.field.placeholder = "сообщение";
|
||||
view.field.maxLength = view.limit;
|
||||
view.field = el("div", "input__field input__field--text");
|
||||
view.edit = editing(view.field);
|
||||
// Поле ввода без input: экранному диктору о нём говорят роль и подпись,
|
||||
// подпись же стоит подсказкой в пустом блоке (docs/ui.md, «Доступность»).
|
||||
view.field.setAttribute("role", "textbox");
|
||||
view.field.setAttribute("aria-multiline", "true");
|
||||
hint(view, "сообщение");
|
||||
// Клавиша Enter на мобильном переносит строку, а не отправляет: отправка
|
||||
// там на кнопке «>» (docs/ui.md, «Чат»). Поэтому «enter», а не «send»:
|
||||
// подпись клавиши обещает то, что клавиша делает.
|
||||
view.field.enterKeyHint = "enter";
|
||||
// Сообщение — обычная речь, а не ник и не пароль: заглавная в начале
|
||||
// предложения и исправление опечаток тут к месту (в формах входа
|
||||
// и «нового чата» они, наоборот, выключены).
|
||||
view.field.autocapitalize = "sentences";
|
||||
view.field.setAttribute("autocorrect", "on");
|
||||
|
||||
view.counter = el("span", "counter");
|
||||
view.counter.hidden = true;
|
||||
@@ -223,17 +243,63 @@ function composer(view) {
|
||||
row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send);
|
||||
form.append(view.bar, row);
|
||||
|
||||
view.field.addEventListener("input", () => count(view));
|
||||
view.field.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.isComposing) {
|
||||
// Предел держится до вставки, а не после: обрезается приходящее,
|
||||
// а набранное остаётся на месте (ADR-071).
|
||||
view.field.addEventListener("beforeinput", (event) => cap(view, event));
|
||||
view.field.addEventListener("input", (event) => {
|
||||
if (event.isComposing) {
|
||||
// Пока идёт композиция IME, содержимое не трогаем: правка оборвала
|
||||
// бы её на полуслове, а подтверждённое не попало бы в блок вовсе.
|
||||
// Подтверждённое разберёт compositionend (ADR-071).
|
||||
return;
|
||||
}
|
||||
if (!wide()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
submit(view);
|
||||
fit(view);
|
||||
tail(view);
|
||||
count(view);
|
||||
});
|
||||
view.field.addEventListener("compositionend", () => {
|
||||
fit(view);
|
||||
tail(view);
|
||||
count(view);
|
||||
});
|
||||
view.field.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (wide() && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit(view);
|
||||
return;
|
||||
}
|
||||
if (view.edit !== "plaintext-only") {
|
||||
// Блок без plaintext-only ставит на Enter <div> или <br>, а значение
|
||||
// читается textContent: перенос вписываем сами (ADR-069).
|
||||
event.preventDefault();
|
||||
insert(view, "\n");
|
||||
}
|
||||
});
|
||||
if (view.edit !== "plaintext-only") {
|
||||
// plaintext-only приносит из буфера и мыши только текст сам. Без него
|
||||
// в блок приезжает чужая разметка, поэтому вставка и перенос — наши.
|
||||
view.field.addEventListener("paste", (event) => {
|
||||
event.preventDefault();
|
||||
insert(view, event.clipboardData?.getData("text/plain") ?? "");
|
||||
});
|
||||
view.field.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
insert(view, event.dataTransfer?.getData("text/plain") ?? "");
|
||||
});
|
||||
// Перетаскивание изнутри блока браузер сделал бы переносом: вписал бы
|
||||
// текст на новом месте и убрал со старого. Вставку мы делаем сами,
|
||||
// отменяя действие по умолчанию, — уносить исходное браузеру нечем,
|
||||
// и перенос молча оборачивался бы удвоением. Объявляем копирование:
|
||||
// тогда это честная копия (ADR-071).
|
||||
view.field.addEventListener("dragstart", (event) => {
|
||||
if (event.dataTransfer !== null) {
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
});
|
||||
}
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
submit(view);
|
||||
@@ -242,19 +308,236 @@ function composer(view) {
|
||||
return form;
|
||||
}
|
||||
|
||||
// editing включает редактирование блока и отдаёт значение, которое
|
||||
// применилось. Нужен plaintext-only: в нём браузер кладёт в блок только
|
||||
// текст, чем бы ни был буфер обмена, и разметке взяться неоткуда.
|
||||
//
|
||||
// Знают его не все браузеры, и незнакомое значение атрибута делает блок
|
||||
// нередактируемым вовсе — то есть строка ввода перестала бы работать молча.
|
||||
// Поэтому оно не назначается, а проверяется чтением: не применилось —
|
||||
// остаётся "true", а вставку, перетаскивание и перенос строки разбирает
|
||||
// composer (ADR-069).
|
||||
function editing(field) {
|
||||
try {
|
||||
field.contentEditable = "plaintext-only";
|
||||
} catch {
|
||||
// Браузер, которому это значение незнакомо, бросает SyntaxError.
|
||||
}
|
||||
if (field.contentEditable === "plaintext-only") {
|
||||
return "plaintext-only";
|
||||
}
|
||||
field.contentEditable = "true";
|
||||
return "true";
|
||||
}
|
||||
|
||||
// hint — подсказка в пустой строке ввода (docs/ui.md, «Чат»). placeholder
|
||||
// у редактируемого блока не бывает: текст кладётся атрибутом, а рисует его
|
||||
// правило CSS через :empty::before. Тот же текст — подпись для экранного
|
||||
// диктора: другой у строки ввода нет.
|
||||
function hint(view, text) {
|
||||
view.field.dataset.hint = text;
|
||||
view.field.setAttribute("aria-label", text);
|
||||
}
|
||||
|
||||
// allow открывает и закрывает ввод: предупреждение о ключе и уход
|
||||
// из комнаты гасят и блок, и кнопку «>» (ADR-016, ADR-044). У блока нет
|
||||
// disabled — закрытый перестаёт быть редактируемым и говорит об этом
|
||||
// экранному диктору.
|
||||
function allow(view, on) {
|
||||
view.field.contentEditable = on ? view.edit : "false";
|
||||
if (on) {
|
||||
view.field.removeAttribute("aria-disabled");
|
||||
} else {
|
||||
view.field.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
view.send.disabled = !on;
|
||||
}
|
||||
|
||||
// cap держит предел до вставки: обрезается то, что приходит в блок,
|
||||
// а набранное остаётся на месте — так же, как считал maxLength у textarea
|
||||
// (ADR-071). Влезающее браузер вставляет сам: тогда и отмена (cmd+z)
|
||||
// остаётся его, а не нашей.
|
||||
//
|
||||
// Композицию IME не трогаем вовсе: отменённая на полуслове, она уносит
|
||||
// с собой и подтверждённый ввод. Лишнее в ней снимет fit на compositionend.
|
||||
function cap(view, event) {
|
||||
if (event.isComposing || event.inputType === "insertCompositionText") {
|
||||
return;
|
||||
}
|
||||
if (!event.inputType.startsWith("insert")) {
|
||||
return;
|
||||
}
|
||||
// Перенос строки и прочее без текста считаем одним символом: тогда cap
|
||||
// вмешивается только тогда, когда места не осталось совсем.
|
||||
const text = event.data ?? event.dataTransfer?.getData("text/plain") ?? "";
|
||||
const range = target(view, event);
|
||||
if ((text === "" ? 1 : text.length) <= free(view, range)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
insert(view, text, range);
|
||||
}
|
||||
|
||||
// target — куда пойдёт вставка: диапазон, который браузер собирается
|
||||
// заменить (beforeinput знает его точно — это не всегда выделение:
|
||||
// вставка мышью идёт туда, куда отпустили), иначе выделение в блоке.
|
||||
function target(view, event = null) {
|
||||
const ranges = event?.getTargetRanges?.() ?? [];
|
||||
if (ranges.length > 0) {
|
||||
const range = document.createRange();
|
||||
range.setStart(ranges[0].startContainer, ranges[0].startOffset);
|
||||
range.setEnd(ranges[0].endContainer, ranges[0].endOffset);
|
||||
return view.field.contains(range.commonAncestorContainer) ? range : null;
|
||||
}
|
||||
const selection = getSelection();
|
||||
const now = selection !== null && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
return now !== null && view.field.contains(now.commonAncestorContainer) ? now : null;
|
||||
}
|
||||
|
||||
// free — сколько символов ещё влезет туда, куда идёт вставка: предел минус
|
||||
// то, что останется от набранного, когда заменяемое уйдёт.
|
||||
function free(view, range) {
|
||||
return view.limit - (value(view).length - (range === null ? 0 : range.toString().length));
|
||||
}
|
||||
|
||||
// value — набранное так, как оно уедет собеседнику. В plaintext-only
|
||||
// браузер держит последнюю строку вторым «\n» в самом конце: в textContent
|
||||
// он виден, а в сообщении его нет — его снимает trim в sync.send. Считаем
|
||||
// и режем по тому же, что отправляем (ADR-071). В запасном пути последнюю
|
||||
// строку держит <br>, которого в textContent нет вовсе, и перенос в конце
|
||||
// там настоящий.
|
||||
function value(view) {
|
||||
const text = view.field.textContent;
|
||||
return view.edit === "plaintext-only" && text.endsWith("\n") ? text.slice(0, -1) : text;
|
||||
}
|
||||
|
||||
// cut режет текст по пределу, не разрывая суррогатную пару: половина пары
|
||||
// уехала бы собеседнику битым символом.
|
||||
function cut(text, limit) {
|
||||
if (limit <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
const last = text.charCodeAt(limit - 1);
|
||||
return text.slice(0, last >= 0xd800 && last <= 0xdbff ? limit - 1 : limit);
|
||||
}
|
||||
|
||||
// fit — последняя страховка предела (docs/ui.md, «Чат»): лишнее в блок
|
||||
// попадает мимо cap — композицией IME или вставкой, о которой браузер
|
||||
// не рассказал. Снимается ровно хвост сверх предела, а не переписывается
|
||||
// блок целиком: правка узлов оставляет курсор на месте и не сносит стек
|
||||
// отмены. Опустевший блок остаётся без узлов: подсказка стоит правилом
|
||||
// :empty, а браузер оставляет в опустевшем блоке <br>, и с ним подсказки
|
||||
// не видно.
|
||||
function fit(view) {
|
||||
const raw = view.field.textContent;
|
||||
const text = value(view);
|
||||
const keep = cut(text, view.limit);
|
||||
if (keep.length < text.length) {
|
||||
// Вместе с лишним уходит и заполнитель последней строки, если он есть:
|
||||
// он в самом конце, а браузер ставит его снова, когда понадобится.
|
||||
trim(view.field, raw.length - keep.length);
|
||||
return;
|
||||
}
|
||||
if (raw === "" && view.field.firstChild !== null) {
|
||||
clear(view.field);
|
||||
}
|
||||
}
|
||||
|
||||
// trim снимает с хвоста блока лишние единицы текста, не трогая остального:
|
||||
// textContent = … переписал бы блок целиком и унёс бы и курсор, и отмену.
|
||||
function trim(field, extra) {
|
||||
const walker = document.createTreeWalker(field, NodeFilter.SHOW_TEXT);
|
||||
const nodes = [];
|
||||
while (walker.nextNode() !== null) {
|
||||
nodes.push(walker.currentNode);
|
||||
}
|
||||
let left = extra;
|
||||
for (let i = nodes.length - 1; i >= 0 && left > 0; i -= 1) {
|
||||
const node = nodes[i];
|
||||
const take = Math.min(left, node.length);
|
||||
node.deleteData(node.length - take, take);
|
||||
left -= take;
|
||||
}
|
||||
}
|
||||
|
||||
// insert вписывает текст туда, куда идёт вставка, обрезая его по свободному
|
||||
// месту. Своими руками, а не execCommand: тот в Chrome разбирает перенос
|
||||
// строки в <div>, а в блоке живёт только текст.
|
||||
function insert(view, text, where) {
|
||||
// where — диапазон, который назвал beforeinput. Его не передали — берём
|
||||
// выделение; выделения в блоке нет — вписываем в конец.
|
||||
const range = where === undefined ? target(view) : where;
|
||||
const fitted = cut(text, free(view, range));
|
||||
if (fitted === "") {
|
||||
return;
|
||||
}
|
||||
const node = document.createTextNode(fitted);
|
||||
const selection = getSelection();
|
||||
if (range === null) {
|
||||
view.field.append(node);
|
||||
} else {
|
||||
range.deleteContents();
|
||||
range.insertNode(node);
|
||||
}
|
||||
if (selection !== null) {
|
||||
const at = document.createRange();
|
||||
at.setStartAfter(node);
|
||||
at.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(at);
|
||||
}
|
||||
// Вставка делит текст на два-три соседних узла; курсор normalize
|
||||
// переставляет сам.
|
||||
view.field.normalize();
|
||||
// Своё изменение блока события input не порождает: счётчик, предел
|
||||
// и последнюю строку трогаем руками.
|
||||
fit(view);
|
||||
tail(view);
|
||||
count(view);
|
||||
}
|
||||
|
||||
// tail держит последнюю пустую строку видимой. Перенос в самом конце
|
||||
// браузер не рисует — строки после него нет, — и курсор оставался бы
|
||||
// на прежней строке, а набранное дальше уезжало бы перед переносом.
|
||||
// Пустую строку держит <br>: тот самый заполнитель, который браузер сам
|
||||
// ставит в опустевший блок. В textContent его нет, и на отправляемый текст
|
||||
// он не влияет.
|
||||
//
|
||||
// В plaintext-only это не нужно: там последнюю строку браузер ведёт сам,
|
||||
// своим переносом, и лишний заполнитель дал бы вторую пустую строку.
|
||||
function tail(view) {
|
||||
if (view.edit === "plaintext-only") {
|
||||
return;
|
||||
}
|
||||
const last = view.field.lastChild;
|
||||
const wants = view.field.textContent.endsWith("\n");
|
||||
if (wants && (last === null || last.nodeName !== "BR")) {
|
||||
view.field.append(document.createElement("br"));
|
||||
return;
|
||||
}
|
||||
if (!wants && last !== null && last.nodeName === "BR") {
|
||||
last.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// count — счётчик остатка: появляется после порога (docs/ui.md, «Чат»).
|
||||
// Считается то, что уедет: заполнитель последней строки в счёт не идёт
|
||||
// (ADR-071).
|
||||
function count(view) {
|
||||
const length = view.field.value.length;
|
||||
const length = value(view).length;
|
||||
view.counter.textContent = String(view.limit - length);
|
||||
view.counter.hidden = length <= COUNTER_AT;
|
||||
}
|
||||
|
||||
function submit(view) {
|
||||
const text = view.field.value;
|
||||
const text = view.field.textContent;
|
||||
if (view.blocked || view.gone || text.trim() === "") {
|
||||
return;
|
||||
}
|
||||
view.field.value = "";
|
||||
view.field.textContent = "";
|
||||
count(view);
|
||||
run(view, () => sync.send(view.chatId, text));
|
||||
}
|
||||
@@ -597,9 +880,8 @@ async function checkPeer(view) {
|
||||
return;
|
||||
}
|
||||
view.blocked = !!record?.pending;
|
||||
// Ввод заблокирован целиком: и поле, и кнопка «>» на мобильном.
|
||||
view.field.disabled = view.blocked;
|
||||
view.send.disabled = view.blocked;
|
||||
// Ввод заблокирован целиком: и блок, и кнопка «>» на мобильном.
|
||||
allow(view, !view.blocked);
|
||||
paintBar(view);
|
||||
}
|
||||
|
||||
|
||||
+30
-2
@@ -5,6 +5,12 @@ import * as pwa from "../pwa.js";
|
||||
import { INSTALL_IOS, clear, el, mark } from "./dom.js";
|
||||
import { mount } from "./chats.js";
|
||||
|
||||
// Время коммита — в местной зоне и коротко: день с месяцем и часы
|
||||
// с минутами (ADR-067). Год не показывается: версия отвечает на вопрос
|
||||
// «что сейчас работает», а не ведёт летопись.
|
||||
const BUILD_DAY = new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||
const BUILD_TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
// frame отдаёт корень, место под экран и отписку списка чатов.
|
||||
// screen — что показывать на мобильном, где виден один экран за раз:
|
||||
// "list" или "screen". active — чат, который сейчас открыт.
|
||||
@@ -38,17 +44,39 @@ function side(ctx, active) {
|
||||
list.append(add, items);
|
||||
nav.append(list);
|
||||
|
||||
const foot = el("div", "foot");
|
||||
const me = el("button", "me");
|
||||
me.type = "button";
|
||||
const dot = el("i");
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
me.append(dot, el("span", null, `ты: @${ctx.me.nick}`));
|
||||
me.append(dot, el("span", "me__nick", `ты: @${ctx.me.nick}`));
|
||||
me.addEventListener("click", () => ctx.go("#/settings"));
|
||||
nav.append(me);
|
||||
foot.append(me);
|
||||
const version = build(ctx.config);
|
||||
if (version !== null) {
|
||||
foot.append(el("span", "build", version));
|
||||
}
|
||||
nav.append(foot);
|
||||
|
||||
return { nav, dispose: mount(items, ctx, active) };
|
||||
}
|
||||
|
||||
// build — версия и время коммита из GET /api/config (ADR-065, ADR-067).
|
||||
// Конфигурации нет — офлайн-старт до первого ответа сервера — значит,
|
||||
// и строки нет: выдумывать версию не из чего. Время без версии не бывает:
|
||||
// её сервер отдаёт всегда, хотя бы как «unknown».
|
||||
function build(config) {
|
||||
const version = config?.version;
|
||||
if (typeof version !== "string" || version === "") {
|
||||
return null;
|
||||
}
|
||||
const at = config?.commitAt;
|
||||
if (!Number.isFinite(at) || at <= 0) {
|
||||
return version;
|
||||
}
|
||||
return `${version} · ${BUILD_DAY.format(at)} ${BUILD_TIME.format(at)}`;
|
||||
}
|
||||
|
||||
// banner — баннер установки на iOS: пуши там работают только
|
||||
// у установленного приложения (ADR-011). Крестик закрывает его насовсем.
|
||||
async function banner(place) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Запрет масштабирования — страховка к строке viewport (ADR-066).
|
||||
//
|
||||
// Основное средство — `maximum-scale=1, user-scalable=no` в index.html
|
||||
// и `touch-action: manipulation` в app.css. Safari вправе не послушаться:
|
||||
// он и раньше игнорировал `user-scalable=no` целиком. Здесь то, что
|
||||
// работает независимо от его настроения, — и ничего больше: прокрутка,
|
||||
// свайпы и обычные нажатия проходят как проходили.
|
||||
|
||||
// Щипок в Safari — это gesturestart/gesturechange/gestureend. В других
|
||||
// браузерах их не бывает вовсе, и подписка ничего не стоит.
|
||||
const GESTURES = ["gesturestart", "gesturechange", "gestureend"];
|
||||
|
||||
// Два тапа подряд ближе этого срока и этого расстояния друг к другу —
|
||||
// двойной тап, то есть масштабирование. Пороги браузерные: свои цифры
|
||||
// здесь всё равно ничем не проверить.
|
||||
const TAP_APART = 350;
|
||||
const TAP_NEAR = 40;
|
||||
|
||||
// Прошлый тап. До первого касания его не было вовсе: иначе тап в углу
|
||||
// в первые триста миллисекунд жизни страницы сошёл бы за второй.
|
||||
const last = { at: -Infinity, x: 0, y: 0 };
|
||||
|
||||
// lock вешает страховку. Слушатели непассивные: пассивному
|
||||
// preventDefault не даёт ничего.
|
||||
export function lock() {
|
||||
for (const name of GESTURES) {
|
||||
document.addEventListener(name, stop, { passive: false });
|
||||
}
|
||||
document.addEventListener("touchend", tap, { passive: false });
|
||||
}
|
||||
|
||||
function stop(event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// tap гасит второй тап подряд — он и масштабирует. Первый доходит
|
||||
// до страницы целиком, поэтому нажатия, свайпы и прокрутка не меняются.
|
||||
function tap(event) {
|
||||
// Второй палец на экране — это щипок или прокрутка двумя, не тап.
|
||||
if (event.touches.length > 0 || event.changedTouches.length !== 1) {
|
||||
last.at = -Infinity;
|
||||
return;
|
||||
}
|
||||
const touch = event.changedTouches[0];
|
||||
const quick = event.timeStamp - last.at <= TAP_APART;
|
||||
const near = Math.abs(touch.clientX - last.x) <= TAP_NEAR
|
||||
&& Math.abs(touch.clientY - last.y) <= TAP_NEAR;
|
||||
last.at = event.timeStamp;
|
||||
last.x = touch.clientX;
|
||||
last.y = touch.clientY;
|
||||
if (!quick || !near || control(event.target)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// control — элемент управления. Второй тап по нему не гасится:
|
||||
// preventDefault на touchend уносит с собой click, а нажать кнопку
|
||||
// дважды подряд — обычное дело. Цена мала: до элементов управления зум
|
||||
// по двойному тапу не доходит и без страховки — `touch-action:
|
||||
// manipulation` стоит на документе.
|
||||
//
|
||||
// Строка сообщения — редактируемый блок, а не textarea (ADR-069), и она
|
||||
// в этом перечне: погашенный тап не доносит до неё ни click, ни фокус,
|
||||
// то есть второй тап подряд не ставил бы курсор в поле ввода.
|
||||
function control(target) {
|
||||
return typeof target?.closest === "function"
|
||||
&& target.closest("button, input, textarea, select, label, a, [contenteditable]") !== null;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
|
||||
// и старые кэши уходят в activate.
|
||||
|
||||
const VERSION = "v4";
|
||||
const VERSION = "v5";
|
||||
const CACHE = `bare-${VERSION}`;
|
||||
|
||||
// Оболочка — всё, из чего клиент поднимается без сети. Список явный:
|
||||
@@ -25,6 +25,7 @@ const SHELL = [
|
||||
"/js/pwa.js",
|
||||
"/js/sync.js",
|
||||
"/js/ulid.js",
|
||||
"/js/zoom.js",
|
||||
"/js/ui/auth.js",
|
||||
"/js/ui/chat.js",
|
||||
"/js/ui/chats.js",
|
||||
@@ -54,6 +55,25 @@ self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
|
||||
});
|
||||
|
||||
// Установленная версия ждёт очереди, пока страница не попросит её включить
|
||||
// (ADR-068). Просит именно страница: в установленном на «Домой»
|
||||
// приложении перезагрузить оболочку больше нечем, а терять набранное
|
||||
// в строке ввода нельзя — знает об этом только она.
|
||||
//
|
||||
// На «version» воркер называет свою версию. По ней страница отличает новую
|
||||
// оболочку от того же файла воркера, отданного сервером заново: первая
|
||||
// стоит перезагрузки, второй — нет (ADR-070). Ответ уходит каналом
|
||||
// вопроса: вопросов бывает два подряд, к разным воркерам.
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data === "skip-waiting") {
|
||||
self.skipWaiting();
|
||||
return;
|
||||
}
|
||||
if (event.data === "version") {
|
||||
event.ports[0]?.postMessage(VERSION);
|
||||
}
|
||||
});
|
||||
|
||||
// activate уносит кэши прежних версий: имя кэша содержит версию, и всё,
|
||||
// что названо иначе, — прошлый релиз. clients.claim берёт под контроль
|
||||
// уже открытую страницу: без этого первый запуск остался бы без кэша
|
||||
|
||||
Reference in New Issue
Block a user