Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.
Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.
ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.
Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.
Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.
Чеклист ручной проверки на iPhone и Android — в docs/plan.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
109 lines
3.8 KiB
JavaScript
109 lines
3.8 KiB
JavaScript
// Сборка узлов. innerHTML не используется нигде: сообщения и ники —
|
|
// пользовательские данные (docs/ui.md, CSP из ADR-021).
|
|
|
|
const SVG = "http://www.w3.org/2000/svg";
|
|
|
|
// DESKTOP — порог десктопа: сайдбар и чат рядом, один экран за раз кончается
|
|
// (docs/ui.md, «Каркас»). Экраны спрашивают ширину в момент события, а не
|
|
// перерисовываются на каждое изменение размера.
|
|
export const DESKTOP = "(min-width: 760px)";
|
|
|
|
export function wide() {
|
|
return matchMedia(DESKTOP).matches;
|
|
}
|
|
|
|
// INSTALL_IOS — текст про установку на iOS. Он один и тот же в баннере
|
|
// над списком чатов и в настройках (docs/ui.md), поэтому и живёт в одном
|
|
// месте.
|
|
export const INSTALL_IOS = "уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»";
|
|
|
|
export function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) {
|
|
node.className = className;
|
|
}
|
|
if (text !== undefined) {
|
|
node.textContent = text;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
export function clear(node) {
|
|
while (node.firstChild) {
|
|
node.removeChild(node.firstChild);
|
|
}
|
|
}
|
|
|
|
// mark — знак «скобы»: четыре угла рамки (docs/identity/brief.md).
|
|
export function mark() {
|
|
const svg = document.createElementNS(SVG, "svg");
|
|
svg.setAttribute("class", "mark");
|
|
svg.setAttribute("viewBox", "0 0 64 64");
|
|
svg.setAttribute("fill", "none");
|
|
svg.setAttribute("stroke", "currentColor");
|
|
svg.setAttribute("stroke-width", "7");
|
|
svg.setAttribute("aria-hidden", "true");
|
|
for (const d of ["M10 26V10h16", "M38 10h16v16", "M54 38v16H38", "M26 54H10V38"]) {
|
|
const path = document.createElementNS(SVG, "path");
|
|
path.setAttribute("d", d);
|
|
svg.append(path);
|
|
}
|
|
return svg;
|
|
}
|
|
|
|
// field — подпись и строка ввода. Отдаёт и то, и другое: подпись идёт
|
|
// в форму, ввод нужен обработчику.
|
|
export function field(label, { type = "text", name, autocomplete } = {}) {
|
|
const wrap = el("label", "field");
|
|
wrap.append(el("span", null, label));
|
|
const input = el("input");
|
|
input.type = type;
|
|
if (name) {
|
|
input.name = name;
|
|
}
|
|
if (autocomplete) {
|
|
input.autocomplete = autocomplete;
|
|
}
|
|
input.autocapitalize = "off";
|
|
input.spellcheck = false;
|
|
wrap.append(input);
|
|
return { wrap, input };
|
|
}
|
|
|
|
export function button(text, className = "button") {
|
|
const node = el("button", className, text);
|
|
node.type = "button";
|
|
return node;
|
|
}
|
|
|
|
// confirmPanel — вопрос и две кнопки; спрятан, пока не спросили.
|
|
// Вопрос отдаётся наружу: его текст бывает известен только к моменту показа.
|
|
export function confirmPanel(question, yesLabel) {
|
|
const root = el("div");
|
|
root.hidden = true;
|
|
const text = el("p", "confirm", question);
|
|
const yes = button(yesLabel);
|
|
const no = button("отмена");
|
|
const row = el("div", "row");
|
|
row.append(yes, no);
|
|
root.append(text, row);
|
|
return { root, text, yes, no };
|
|
}
|
|
|
|
// message — строка состояния под формой: ошибка цветом mark, ответ — mute.
|
|
export function message() {
|
|
const node = el("p", "message");
|
|
node.setAttribute("aria-live", "polite");
|
|
return node;
|
|
}
|
|
|
|
export function setError(node, text) {
|
|
node.className = "message message--error";
|
|
node.textContent = text;
|
|
}
|
|
|
|
export function setNote(node, text) {
|
|
node.className = "message";
|
|
node.textContent = text;
|
|
}
|