Сервер: пакет 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
70 lines
2.6 KiB
JavaScript
70 lines
2.6 KiB
JavaScript
// Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас»
|
||
// и «Список чатов».
|
||
|
||
import * as pwa from "../pwa.js";
|
||
import { INSTALL_IOS, clear, el, mark } from "./dom.js";
|
||
import { mount } from "./chats.js";
|
||
|
||
// frame отдаёт корень, место под экран и отписку списка чатов.
|
||
// screen — что показывать на мобильном, где виден один экран за раз:
|
||
// "list" или "screen". active — чат, который сейчас открыт.
|
||
export function frame(ctx, screen, active = null) {
|
||
const root = el("div", "shell");
|
||
root.dataset.screen = screen;
|
||
const main = el("main", "main");
|
||
const { nav, dispose } = side(ctx, active);
|
||
root.append(nav, main);
|
||
return { root, main, dispose };
|
||
}
|
||
|
||
function side(ctx, active) {
|
||
const nav = el("nav", "side");
|
||
|
||
const brand = el("div", "brand");
|
||
brand.append(mark(), el("span", null, "bare"));
|
||
nav.append(brand);
|
||
|
||
// Баннер установки — над списком чатов (docs/ui.md, «Баннер установки»).
|
||
// Место под него занимается сразу, содержимое приезжает из meta.
|
||
const place = el("div", "banner-slot");
|
||
nav.append(place);
|
||
banner(place);
|
||
|
||
const list = el("div", "list");
|
||
const add = el("button", "item item--new", "+ новый чат");
|
||
add.type = "button";
|
||
add.addEventListener("click", () => ctx.go("#/new"));
|
||
const items = el("div");
|
||
list.append(add, items);
|
||
nav.append(list);
|
||
|
||
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.addEventListener("click", () => ctx.go("#/settings"));
|
||
nav.append(me);
|
||
|
||
return { nav, dispose: mount(items, ctx, active) };
|
||
}
|
||
|
||
// banner — баннер установки на iOS: пуши там работают только
|
||
// у установленного приложения (ADR-011). Крестик закрывает его насовсем.
|
||
async function banner(place) {
|
||
if (!pwa.iosBrowser() || await pwa.bannerHidden()) {
|
||
return;
|
||
}
|
||
const box = el("div", "banner");
|
||
box.append(el("p", "banner__text", INSTALL_IOS));
|
||
const close = el("button", "banner__close", "×");
|
||
close.type = "button";
|
||
close.setAttribute("aria-label", "закрыть");
|
||
close.addEventListener("click", () => {
|
||
clear(place);
|
||
pwa.hideBanner();
|
||
});
|
||
box.append(close);
|
||
place.append(box);
|
||
}
|