Хранение пользовательских настроек
Модель плана существует в памяти и не смешивает draft с подтверждённой записью. Теперь выберем данные, которым действительно полезно переживать новое открытие. Результат урока — самостоятельный модуль несекретных настроек интерфейса и правило выбора между browser storage, хранилищами Telegram и собственным сервером. Мы сохраним только предпочтение плотности карточек; bearer token и личный план в этот механизм не включаются.
Хранение часто добавляют одной строкой localStorage.setItem, а затем считают сохранёнными вообще все данные приложения. Но у разных значений разные владельцы и смысл. Плотность списка относится к привычке этого браузера, план — к подтверждённому аккаунту, токен — к временной возможности доступа. Если сложить их в один объект, перезагрузка неожиданно восстанавливает чужой base и давно истёкший токен вместе с безобидным оформлением. Разделение модели прошлого урока помогает не допустить этой связи.
Небольшая настройка без персональных данных
Для первого варианта выбрана плотность comfortable либо compact. Она не содержит курса, Telegram ID, имени, initData или срока сессии. Значение можно считать общим предпочтением origin на устройстве, даже если в Telegram менялись аккаунты. Если будущий продукт потребует разные настройки у разных людей, это станет другим серверным договором; нынешний ключ не является пользовательским профилем.
Версия записи нужна для обновления формата. Сегодня object содержит schemaVersion и density. Позднее добавление fontScale не должно приводить к непредсказуемой интерпретации старого JSON. Неизвестная версия возвращает согласованное значение по умолчанию. Мы не удаляем всю localStorage, потому что этим можно затронуть другие функции того же origin. Очистка относится к одному принадлежащему модулю ключу.
Полный settings.js принимает объект storage как зависимость. Это позволяет экрану выбрать доступный механизм и не читать window.localStorage в момент импорта, когда доступ уже может быть запрещён политикой браузера:
const key = 'study-plan.ui.v1';
const defaultValue = () => ({schemaVersion: 1, density: 'comfortable'});
const valid = value => value && value.schemaVersion === 1 &&
['comfortable', 'compact'].includes(value.density);
export function createSettings(storage) {
return {
read() {
try {
const raw = storage?.getItem(key);
if (!raw || raw.length > 200) return defaultValue();
const value = JSON.parse(raw);
return valid(value) ? {schemaVersion: 1, density: value.density} : defaultValue();
} catch { return defaultValue(); }
},
write(value) {
if (!valid(value)) throw new Error('Invalid UI setting');
try {
if (!storage) return false;
storage.setItem(key, JSON.stringify({schemaVersion: 1, density: value.density}));
return true;
} catch { return false; }
},
clear() {
try { storage?.removeItem(key); return true; }
catch { return false; }
}
};
}
export function browserStorage(scope = window) {
try { return scope.localStorage; }
catch { return null; }
}
Использование разделяет получение механизма и управление экраном: createSettings(browserStorage()). Read не обещает, что когда-то выполненный write обязан сохраниться навсегда. Пользователь может очистить данные, браузер — ограничить доступ, режим WebView — иначе управлять жизненным циклом. Write возвращает false при недоступности; интерфейс может применить выбранную плотность в памяти и честно сообщить, что предпочтение не сохранится после закрытия. Отказ storage не должен блокировать чтение каталога.
Данные из хранилища снова являются входом
Даже наш маленький JSON проверяется по схеме. Содержимое storage изменяется другими вкладками, старой версией приложения или человеком через инструменты браузера. Поэтому неизвестное density не превращается в произвольный className, style или путь ресурса. Модуль возвращает только два известных значения. На экране выбор mapped класса определяется программой, а не напрямую исполняемым содержимым JSON.
Проверьте ожидаемый сценарий повреждения: в ключ записана строка, которая не является JSON. Read возвращает comfortable и не ломает весь экран. Следующий случай — schemaVersion 2 без поддержанного мигратора; результат также стандартный. Это не попытка восстановить любой формат любой ценой. После отдельного изменения схемы можно добавить точную миграцию одного известного старого объекта, сохранив правило ограниченного набора значений.
Что предлагает Telegram
У Telegram есть CloudStorage, а также DeviceStorage и SecureStorage. CloudStorage относится к пользовательским данным бота в облаке; DeviceStorage предоставляет локальное хранение в клиенте и появился в Bot API 9.0. Их асинхронные callback имеют договор ошибок. Это другой интерфейс, поэтому показанный синхронный модуль не принимает SDK объект как готовую замену localStorage. Для него потребуется самостоятельный адаптер, который ожидает результат и проверяет поддержку функции.
SecureStorage предназначен для чувствительных данных на устройстве, но его название не разрешает передавать bot token клиенту. Секрет бота остаётся серверным при любом устройстве хранения. В нашем выбранном сценарии bearer живёт только в памяти страницы и имеет короткий срок; мы не добавляем постоянное восстановление сессии через SDK. Такой переход потребует нового решения об отзыве, восстановлении доступа и поведении после смены аккаунта, а не замены имени функции.
План остаётся на сервере
Подтверждённый base читается через собственный API после сессии. LocalStorage не является авторитетной копией плана и не обеспечивает атомарную version. Даже если вы решите сохранить несекретный draft, понадобятся привязка к подтверждённому владельцу, версия схемы, срок восстановления и понятный статус «несохранённый выбор». Сейчас этого механизма нет. Модуль настройки сознательно не принимает объект модели createPlanState и не сериализует pending целиком.
Особенно опасно сохранять operationId вместе с чужим owner hint и автоматически повторять запись после открытия. Источник личности должен снова подтверждаться сервером. Запись прошлого окна может принадлежать другой сессии того же аккаунта или другому аккаунту вообще. В уроке сети мы сохраняем pending в текущей модели и обсуждаем серверный реестр; постоянный offline writer в этой серии не поставляется. Настройка density не должна превращаться в него случайно.
Не вызывайте запись на каждом render независимо от действия человека. Render может происходить после изменения темы, viewport или сетевого статуса, и эти события не являются новым предпочтением плотности. В нашем сценарии write вызывается при явном изменении настройки, read — при подготовке экрана. Так лишние события хоста не создают поток синхронных операций storage и не маскируют отказ записи. Если появится несколько вкладок, понадобится отдельное правило согласования их настроек: текущий модуль не подписывается на storage event и не обещает мгновенную синхронизацию между окнами.
Изменение оформления
После read экран применяет density к собственной карточке. При смене настройки нужно отдельно решить, немедленно ли меняется интерфейс и что делать при write false. Подход прост: человек видит выбранную плотность сейчас, а статус сохранения предпочтения сообщается отдельно. Не откатывайте весь экран к comfortable только потому, что storage недоступна; иначе неисправность необязательной функции выглядит как поломка каталога. При новом открытии без сохранения вернётся default, что соответствует договору.
Web Storage API относится к origin браузера; он не доказывает текущий Telegram-аккаунт. Персональные действия продолжают требовать сессию и серверную авторизацию. Ожидаемый результат нашего модуля — известное значение восстанавливается, повреждённое получает default, а запрет записи оставляет рабочий интерфейс. Storage, SDK, WebView и JavaScript здесь не выполнялись. Далее сформируем ссылки на конкретный курс, сохранив то же разделение навигационной подсказки и полномочий.