From 8f32e82d14fb4fda914b213edc923b8308cd6e27f9a50b10d26cc84ed48d88e0 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 12 Aug 2026 13:14:23 +0400 Subject: [PATCH 1/4] =?UTF-8?q?=D0=A1=D0=BE=D0=BA=D1=80=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BB=D1=83=D0=B6=D0=B5=D0=B1=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D0=B3=D0=B8=20SHiNE=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BD=D1=83=D1=8E=20=D1=81=D0=BE=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/ChannelMetaTextParser.java | 26 +++++++++++--- VERSION.properties | 4 +-- docs/API/04_Add_Block_to_Blockchain_API.md | 18 +++++----- .../01_Channel_Types_and_CreateChannel.md | 4 +-- docs/Blockchain/11_TEXT_Blocks.md | 20 +++++------ docs/Blockchain/15_TEXT_Attachments.md | 36 ++++++++++--------- docs/Blockchain/16_TEXT_Channel_Meta.md | 16 ++++++--- docs/Blockchain/CHANGELOG.md | 12 +++++++ docs/Blockchain/README.md | 2 +- docs/Personal_Messages/README.md | 2 +- docs/Personal_Messages/Протокол_DM_v1.md | 2 +- .../Технические_вставки_DM_v1.md | 25 ++++++------- shine-UI/js/services/attachment-format.js | 31 +++++++++------- shine-UI/js/services/auth-service.js | 4 +-- shine-UI/js/services/dm-tech-blocks.js | 28 +++++++++++---- 15 files changed, 144 insertions(+), 86 deletions(-) diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java index df48579e..57bd4ebb 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java @@ -6,6 +6,8 @@ import java.util.Map; public final class ChannelMetaTextParser { public static final int MAX_TITLE_CHARS = 50; public static final int MAX_DESCRIPTION_CHARS = 250; + private static final String LEGACY_PREFIX = "', offset); if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag"); - String body = text.substring(offset + " fields = parseFields(body.substring("avatar;".length())); + String rawFields = body.startsWith("ava;") + ? body.substring("ava;".length()) + : body.substring("avatar;".length()); + Map fields = parseFields(rawFields); if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version"); String ar = String.valueOf(fields.getOrDefault("ar", "")).trim(); String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase(); - String sizeRaw = String.valueOf(fields.getOrDefault("size", "")).trim(); + String sizeRaw = String.valueOf(fields.containsKey("sz") ? fields.get("sz") : fields.getOrDefault("size", "")).trim(); if (!ar.matches("^[A-Za-z0-9_-]{43}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_ar"); if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256"); long size; @@ -78,6 +84,16 @@ public final class ChannelMetaTextParser { return new Avatar(ar, sha256, size); } + private static boolean startsWithTagPrefix(String text, int offset) { + return text.startsWith(LEGACY_PREFIX, offset) || text.startsWith(SHORT_PREFIX, offset); + } + + private static int tagPrefixLength(String text, int offset) { + if (text.startsWith(LEGACY_PREFIX, offset)) return LEGACY_PREFIX.length(); + if (text.startsWith(SHORT_PREFIX, offset)) return SHORT_PREFIX.length(); + return 0; + } + private static Map parseFields(String raw) { Map out = new HashMap<>(); for (String part : String.valueOf(raw == null ? "" : raw).split(";")) { diff --git a/VERSION.properties b/VERSION.properties index 7042c4e0..0b4bf2ae 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.36 -server.version=1.4.11 +client.version=1.5.37 +server.version=1.4.12 diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index 88c6857e..576662cf 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -160,20 +160,20 @@ ### Вложения в сообщениях -Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1` или `v=2`. +Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `S:att v=1`. Пример текстового содержимого body: ```text - - + + Текст сообщения ``` Пример вложения с отдельным preview-файлом: ```text - + ``` Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`. @@ -183,8 +183,8 @@ Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст: ```text - - + + Описание канала ``` @@ -197,12 +197,12 @@ Текстовое содержимое body использует тот же формат полного снимка профиля: ```text - - + + Новое описание канала ``` -Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `SHiNE:avatar` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`. +Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `S:ava` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`. ## 7. Хватает ли функций сейчас diff --git a/docs/Blockchain/01_Channel_Types_and_CreateChannel.md b/docs/Blockchain/01_Channel_Types_and_CreateChannel.md index 742a7948..e724430f 100644 --- a/docs/Blockchain/01_Channel_Types_and_CreateChannel.md +++ b/docs/Blockchain/01_Channel_Types_and_CreateChannel.md @@ -16,8 +16,8 @@ Payload включает: Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`: ```text - - + + Описание канала. ``` diff --git a/docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md index c629eb85..226934be 100644 --- a/docs/Blockchain/11_TEXT_Blocks.md +++ b/docs/Blockchain/11_TEXT_Blocks.md @@ -34,7 +34,7 @@ TEXT-тип хранит сообщения, материалы и редакт 7. `subType=90` — `TEXT_CHANNEL_META` - скрытый технический снимок профиля канала; - - содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием; + - содержит line-поля + текст с тегами `S:title`/`S:ava` и описанием; - не отображается как обычное сообщение ленты; - применяется сервером к текущему состоянию канала. @@ -73,7 +73,7 @@ TEXT-тип хранит сообщения, материалы и редакт - Такой edit трактуется как логическое удаление содержимого сообщения. - Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет. -## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`) +## Вложения в текстовых сообщениях (`S:att v=1`) Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`. @@ -84,31 +84,31 @@ TEXT-тип хранит сообщения, материалы и редакт Один блок вложения: ```text - + ``` Для файлов с отдельным превью клиент может использовать расширенный вариант: ```text - + ``` Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст. Обязательные поля: -- `v=1` или `v=2` -- `name` - имя файла, закодированное через `encodeURIComponent` -- `size` - размер файла в байтах +- `v=1` +- `nm` - имя файла, закодированное через `encodeURIComponent` +- `sz` - размер файла в байтах - `sha256` - SHA-256 исходного файла в hex - `ar` - короткий Arweave Transaction ID, без полного URL -- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256` +- при наличии превью дополнительно могут присутствовать `preAr` и `preSha256` Правила клиента: -- если сообщение начинается с одного или нескольких валидных `` блоков, новый клиент скрывает эти блоки и показывает карточки вложений; +- если сообщение начинается с одного или нескольких валидных attach-блоков (``, `` или ``), новый клиент скрывает эти блоки и показывает карточки вложений; - если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных; - старые клиенты без поддержки вложений могут показывать технические строки как обычный текст; - пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок. -Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`. +Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `preAr/preSha256`. diff --git a/docs/Blockchain/15_TEXT_Attachments.md b/docs/Blockchain/15_TEXT_Attachments.md index 9500a5c3..1896401e 100644 --- a/docs/Blockchain/15_TEXT_Attachments.md +++ b/docs/Blockchain/15_TEXT_Attachments.md @@ -1,4 +1,4 @@ -# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`) +# Вложения в TEXT-сообщениях (`S:att v=1`) Документ фиксирует текущий формат вложений в текстовых блоках SHiNE. @@ -15,23 +15,23 @@ ## Общий вид -Один attach-блок: +Канонический attach-блок: ```text - + ``` Блок с превью: ```text - + ``` Несколько вложений идут подряд в самом начале текста: ```text - - + + Текст сообщения ``` @@ -41,18 +41,18 @@ ## Поля -Обязательные поля: +Обязательные поля канонического формата: - `v=1` - версия формата attach-блока; -- `name` - имя файла, закодированное через `encodeURIComponent`; -- `size` - размер файла в байтах; +- `nm` - имя файла, закодированное через `encodeURIComponent`; +- `sz` - размер файла в байтах; - `sha256` - SHA-256 исходного файла в hex, 64 символа; - `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL. -Дополнительные поля для `v=2`: +Дополнительные поля для превью: -- `previewAr` - короткий Arweave Transaction ID файла-превью; -- `previewSha256` - SHA-256 файла-превью в hex. +- `preAr` - короткий Arweave Transaction ID файла-превью; +- `preSha256` - SHA-256 файла-превью в hex. ## Хранение файла @@ -63,7 +63,7 @@ - SHA-256; - Arweave `txId`. -Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`. +Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `preAr/preSha256`. ## Создание вложения в UI @@ -78,7 +78,7 @@ UI поддерживает три сценария: - основной видеофайл; - отдельное изображение-превью. -После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`. +После успешной загрузки в журнале хранится один элемент основного файла, но с полями `preAr/preSha256`. В UI такой элемент помечается как файл `С превью`. При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью. @@ -99,7 +99,7 @@ UI поддерживает три сценария: - изображение показывает как ограниченное по размеру превью; - видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию; - обычный файл показывает как карточку с именем, расширением, размером и скачиванием; -6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок; +6. если у видео есть `preAr/preSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок; 7. при перелистывании останавливает воспроизводящееся видео; 8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение. @@ -118,7 +118,9 @@ UI поддерживает три сценария: - `ar` допускает только короткий Arweave `txId`, полный URL не используется; - MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла; -- старые блоки `v=1` без превью остаются валидными и читаются без изменений; -- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны; +- старые блоки ``, промежуточные `` и новые `` читаются одинаково; +- старые поля `name/size/previewAr/previewSha256` и новые `nm/sz/preAr/preSha256` читаются одинаково; +- старые блоки `v=2` продолжают читаться как legacy-форма вложения с превью; +- `preAr/preSha256` используются только если присутствуют оба поля и оба валидны; - MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями; - проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки. diff --git a/docs/Blockchain/16_TEXT_Channel_Meta.md b/docs/Blockchain/16_TEXT_Channel_Meta.md index 211b1832..6ff73154 100644 --- a/docs/Blockchain/16_TEXT_Channel_Meta.md +++ b/docs/Blockchain/16_TEXT_Channel_Meta.md @@ -18,15 +18,15 @@ В начале текста могут идти технические теги, после них обычный текст описания: ```text - - + + Описание канала. ``` Поддерживаемые теги: -- `` — человекочитаемое имя канала. -- `` — аватар канала в Arweave. +- `` — человекочитаемое имя канала. +- `` — аватар канала в Arweave. ## Правила @@ -44,7 +44,7 @@ - Длина описания — максимум 250 Unicode code points. - `avatar.ar` — Arweave transaction id из 43 символов. - `avatar.sha256` — 64 hex-символа. -- `avatar.size` — положительный размер файла в байтах. +- `avatar.sz` — положительный размер файла в байтах. Если meta-блок невалиден, сервер не применяет его целиком. @@ -59,3 +59,9 @@ ## Замена старых команд Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`. + +## Совместимость + +- Новый UI пишет сокращённые теги `` и ``. +- Серверное чтение поддерживает и старые теги `` / ``. +- Для размера аватара чтение поддерживает и старое поле `size`, и новое поле `sz`. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index e88c277e..33b7595c 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,17 @@ # История изменений документации блокчейна +## 2026-08-12 13:00:00 +0400 +- Базовый коммит-ориентир: `working tree`. +- Канонический текстовый формат служебных тегов сокращён: + - `TEXT_CHANNEL_META` теперь пишет `` и ``; + - вложения теперь пишутся как ``; + - превью во вложениях задаётся полями `preAr/preSha256` без отдельной новой версии `v=2`; + - DM-вставки теперь пишутся с префиксом `` продолжают поддерживаться; + - старые поля `name/size/previewAr/previewSha256` продолжают поддерживаться; + - legacy-вложения `v=2` продолжают читаться как вложения с превью. + ## 2026-08-09 23:30:06 +0400 - Базовый коммит-ориентир: `ee185cf`. - Нумерация `STATUS_ACTION` уточнена под дневник действий: diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index 822b3430..17958027 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -20,7 +20,7 @@ 8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md) Статусные действия пользователя (`msg_type=5`). 9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md) - Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений. + Вложения в TEXT-сообщениях через `S:att v=1`, включая опциональные `preAr/preSha256` для видео и крупных изображений. 10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) Скрытый `TEXT_CHANNEL_META` для профиля канала. 11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md index 2ec4490a..0f24f649 100644 --- a/docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/README.md @@ -6,7 +6,7 @@ - `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers` - `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM` -- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки +- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки Исторический устаревший документ сохранён отдельно: diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 3798f1bb..50fa4ad8 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -96,7 +96,7 @@ - если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`; - если `body` повреждён или структурно битый, показывает `Сообщение повреждено`. -После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `` в начале текста. +После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `` в начале текста. Legacy-вставки `` также продолжают поддерживаться при чтении. Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope. ### 2.4. Источник истины по пользователю diff --git a/docs/Personal_Messages/Технические_вставки_DM_v1.md b/docs/Personal_Messages/Технические_вставки_DM_v1.md index ded7a5fd..436ca0b1 100644 --- a/docs/Personal_Messages/Технические_вставки_DM_v1.md +++ b/docs/Personal_Messages/Технические_вставки_DM_v1.md @@ -18,7 +18,7 @@ Если в начале plaintext стоит один или несколько специальных блоков формата: ```text - + ``` то клиент трактует их как технические вставки. @@ -26,14 +26,14 @@ Техническими считаются только блоки, которые: - стоят строго в начале plaintext; -- начинаются с точного префикса ``. -Если текст не начинается с `` в начале plaintext: +Все корректно распознанные блоки `` или `` в начале plaintext: - не показываются пользователю как сырой текст; - используются клиентом для UI-логики; @@ -45,7 +45,7 @@ Перед отправкой обычного текстового сообщения клиент обязан проверить: -- если пользовательский текст начинается с ` + ``` Правила: @@ -69,14 +69,15 @@ - параметры отделяются `;`; - ключ и значение отделяются `=`; - значения не экранируются в v1; -- формат чувствителен к точному префиксу `Текст ответа +Текст ответа ``` Где поле `id` — это логический идентификатор сообщения: @@ -91,14 +92,14 @@ fromLogin|toLogin|timeMs|nonce - после него может идти обычный текст ответа; - официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения; - если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview; -- в таком случае само сообщение отображается как обычный текст без блока ``. +- в таком случае само сообщение отображается как обычный текст без блока ``. ## 6. Тип `call` ### Успешный звонок ```text - + ``` Где: @@ -108,7 +109,7 @@ fromLogin|toLogin|timeMs|nonce ### Неуспешный звонок ```text - + ``` Допустимые причины в v1: @@ -130,7 +131,7 @@ fromLogin|toLogin|timeMs|nonce Официальный UI SHiNE в v1: -- скрывает все корректные `` блоки в начале plaintext; +- скрывает все корректные `` и legacy `` блоки в начале plaintext; - для `call` строит специальный человекочитаемый текст: - `Звонок: M:SS` - `Звонок: H:MM:SS` diff --git a/shine-UI/js/services/attachment-format.js b/shine-UI/js/services/attachment-format.js index 67291f92..8eda099d 100644 --- a/shine-UI/js/services/attachment-format.js +++ b/shine-UI/js/services/attachment-format.js @@ -1,7 +1,6 @@ import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js'; -const ATTACH_PREFIX = ']*)>\n?/u; +const ATTACH_BLOCK_RE = /^<(?:SHiNE|S):(attach|att);([^>]*)>\n?/u; export const MAX_MESSAGE_ATTACHMENTS = 10; const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000; const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']); @@ -33,8 +32,8 @@ function normalizeName(name) { } function normalizePreview(input = {}) { - const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim(); - const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase(); + const previewTxId = String(input.preAr || input.previewAr || input.ar || input.txId || '').trim(); + const previewSha256Hex = String(input.preSha256 || input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase(); if (!previewTxId || !previewSha256Hex) return null; if (!validateArweaveTxId(previewTxId)) return null; if (!validateSha256Hex(previewSha256Hex)) return null; @@ -47,9 +46,11 @@ function normalizePreview(input = {}) { export function normalizeAttachment(input = {}) { const txId = String(input.ar || input.txId || '').trim(); const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase(); - const size = Number(input.size || input.sizeBytes || 0); - const name = normalizeName(input.name || input.fileName || 'file'); + const size = Number(input.sz || input.size || input.sizeBytes || 0); + const name = normalizeName(input.nm || input.name || input.fileName || 'file'); const preview = normalizePreview(input.preview || { + preAr: input.preAr, + preSha256: input.preSha256, previewAr: input.previewAr, previewSha256: input.previewSha256, }); @@ -75,9 +76,9 @@ export function buildAttachmentBlock(attachment) { const item = normalizeAttachment(attachment); const encodedName = encodeURIComponent(item.name); const previewFields = item.preview - ? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}` + ? `;preAr=${item.preview.ar};preSha256=${item.preview.sha256}` : ''; - return ``; + return ``; } export function composeMessageWithAttachments(text, attachments = []) { @@ -105,16 +106,22 @@ export function parseMessageAttachments(rawText) { let rest = String(rawText || ''); const attachments = []; - while (rest.startsWith(ATTACH_PREFIX)) { + while (true) { const match = rest.match(ATTACH_BLOCK_RE); if (!match) break; - const fields = parseFields(match[1]); + const fields = parseFields(match[2]); try { + const version = Number(fields.v || 0); + if (version !== 1 && version !== 2) { + throw new Error('unsupported attachment version'); + } attachments.push(normalizeAttachment({ - name: decodeURIComponent(String(fields.name || 'file')), - size: fields.size, + nm: decodeURIComponent(String(fields.nm || fields.name || 'file')), + sz: fields.sz || fields.size, sha256: fields.sha256, ar: fields.ar, + preAr: fields.preAr, + preSha256: fields.preSha256, previewAr: fields.previewAr, previewSha256: fields.previewSha256, })); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index b53c860c..3db9363d 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -795,7 +795,7 @@ function composeChannelMetaText({ title = '', description = '', avatar = null } const cleanTitle = validateChannelMetaTitle(title); const cleanDescription = normalizeChannelMetaDescription(description); const rows = []; - if (cleanTitle) rows.push(``); + if (cleanTitle) rows.push(``); if (avatar?.ar) { const size = Number(avatar.size || 0); const sha256 = String(avatar.sha256 || '').trim().toLowerCase(); @@ -803,7 +803,7 @@ function composeChannelMetaText({ title = '', description = '', avatar = null } if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.'); if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.'); if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.'); - rows.push(``); + rows.push(``); } if (cleanDescription) rows.push(cleanDescription); return rows.join('\n'); diff --git a/shine-UI/js/services/dm-tech-blocks.js b/shine-UI/js/services/dm-tech-blocks.js index 0a2616cb..10b2008e 100644 --- a/shine-UI/js/services/dm-tech-blocks.js +++ b/shine-UI/js/services/dm-tech-blocks.js @@ -11,6 +11,16 @@ function defaultParsed(rawText = '') { }; } +function hasTechPrefix(text = '', offset = 0) { + return String(text || '').startsWith('`; + return ``; } const cleanReason = String(reason || '').trim().toLowerCase(); - return ``; + return ``; } export function buildDmReplyTechBlock({ baseKey = '' } = {}) { const cleanBaseKey = String(baseKey || '').trim(); if (!cleanBaseKey) return ''; - return ``; + return ``; } export function parseDmTechBlocks(rawText = '') { const text = String(rawText || ''); - if (!text.startsWith('', cursor); if (end < 0) { if (!blocks.length) return defaultParsed(text); break; } - const inner = text.slice(cursor + 7, end); + const prefixLength = getTechPrefixLength(text, cursor); + if (!prefixLength) break; + const inner = text.slice(cursor + prefixLength, end); const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean); if (!segments.length) { if (!blocks.length) return defaultParsed(text); From b4b23afc1045fe99dd1b4df376a1b8e1bb20c86d817c8bab36ad0c524b579e86 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 12 Aug 2026 17:12:57 +0400 Subject: [PATCH 2/4] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B2=D0=B5=D1=80=D1=85=D0=BD=D0=B5=D0=B5?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/pages/channels-list.js | 174 ++++++++++++++++++++++++++--- shine-UI/styles/components.css | 9 +- 3 files changed, 169 insertions(+), 16 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 0b4bf2ae..3edb3292 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.37 +client.version=1.5.38 server.version=1.4.12 diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 2d7b932c..df1ef38f 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -20,11 +20,16 @@ export const pageMeta = { id: 'channels-list', title: 'Каналы' }; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const MENU_OVERLAY_ID = 'channels-context-menu-overlay'; +const TOP_MENU_OVERLAY_ID = 'channels-top-menu-overlay'; const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_PERSONAL = 100; const DIARY_CHANNEL_NAME = 'diary'; const DIARY_DISPLAY_NAME = 'Дневник'; +const CHANNELS_VIEW_ALL = 'all'; +const CHANNELS_VIEW_OWNED = 'owned'; +const CHANNELS_VIEW_FOLLOWING = 'following'; + function cleanChannelMessagePreview(text) { const parsed = parseMessageAttachments(text); const dmParsed = parseDmTechBlocks(String(parsed.text || '')); @@ -228,6 +233,26 @@ function normalizeComparableLogin(value) { return normalizeLoginInput(value).toLowerCase(); } +function normalizeChannelsViewMode(route = null) { + const mode = String(route?.params?.mode || '').trim().toLowerCase(); + const scope = String(route?.params?.scope || '').trim().toLowerCase(); + if (mode === 'my' || scope === 'owned') return CHANNELS_VIEW_OWNED; + if (mode === 'following' || scope === 'following') return CHANNELS_VIEW_FOLLOWING; + return CHANNELS_VIEW_ALL; +} + +function buildChannelsViewRoute(mode) { + if (mode === CHANNELS_VIEW_OWNED) return 'channels/my'; + if (mode === CHANNELS_VIEW_FOLLOWING) return 'channels/following'; + return 'channels'; +} + +function channelsViewTitle(mode) { + if (mode === CHANNELS_VIEW_OWNED) return 'Мои каналы'; + if (mode === CHANNELS_VIEW_FOLLOWING) return 'Подписки'; + return 'Каналы'; +} + function isFollowedUserVisible(targetLogin) { const expected = normalizeComparableLogin(targetLogin); if (!expected) return false; @@ -617,6 +642,11 @@ function openChannelFinderModal({ navigate }) { function mapMockGroups() { const mapRow = (channel) => ({ ...channel, + sourceBucket: channel.kind === 'subscribed' + ? 'followedChannels' + : channel.kind === 'followed-user-channel' + ? 'followedUsers' + : 'own', route: makeShineChannelRoute({ ownerLogin: String(channel.ownerName || 'channel'), ownerBlockchainName: String(channel.ownerName || ''), @@ -668,6 +698,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) { return { id: rowId, + sourceBucket: bucketKey, route: buildChannelRouteFromSummary(summary, rowId), ownerName: ownerLogin, ownerBlockchainName: summary?.channel?.ownerBlockchainName || '', @@ -721,6 +752,7 @@ function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = { return { id: rowId, + sourceBucket: 'own', route: makeShineChannelRoute({ ownerLogin, ownerBlockchainName, @@ -891,6 +923,98 @@ function closeChannelMenu(listState, clearOpenMenuId = true) { } } +function closeTopChannelsMenu(listState) { + if (typeof listState.topMenuCleanup === 'function') { + listState.topMenuCleanup(); + } + listState.topMenuCleanup = null; + const root = document.getElementById('modal-root'); + if (root) { + const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`); + if (overlay) overlay.remove(); + } +} + +function openTopChannelsMenu({ + listState, + anchorEl, + navigate, + onSubscribeChannel, + onFindChannel, +}) { + closeTopChannelsMenu(listState); + const root = document.getElementById('modal-root'); + if (!root || !anchorEl) return; + + const rect = anchorEl.getBoundingClientRect(); + const menuWidth = Math.min(280, Math.max(220, window.innerWidth - 28)); + let left = rect.right - menuWidth; + left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12)); + + const estimatedHeight = 320; + let top = rect.bottom + 8; + if (top + estimatedHeight > window.innerHeight - 10) { + top = Math.max(12, rect.top - estimatedHeight - 8); + } + + const overlay = document.createElement('div'); + overlay.id = TOP_MENU_OVERLAY_ID; + overlay.className = 'channels-menu-overlay'; + + const menu = document.createElement('div'); + menu.className = 'channel-menu-wrap channel-menu-wrap--portal'; + menu.style.left = `${Math.round(left)}px`; + menu.style.top = `${Math.round(top)}px`; + menu.style.width = `${Math.round(menuWidth)}px`; + + const items = [ + { label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) }, + { label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) }, + { label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) }, + { divider: true }, + { label: 'Новый канал', action: () => navigate('add-channel-view') }, + { label: 'Создать канал', action: () => onSubscribeChannel?.() }, + { divider: true }, + { label: 'Просмотреть канал', action: () => onFindChannel?.() }, + ]; + + items.forEach((item) => { + if (item.divider) { + const divider = document.createElement('div'); + divider.className = 'channel-menu-divider'; + divider.style.height = '1px'; + divider.style.background = 'rgba(255,255,255,0.08)'; + divider.style.margin = '6px 0'; + menu.append(divider); + return; + } + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'channel-menu-item'; + btn.textContent = item.label; + btn.addEventListener('click', () => { + closeTopChannelsMenu(listState); + item.action?.(); + }); + menu.append(btn); + }); + + overlay.append(menu); + root.append(overlay); + + const onOverlayClick = (event) => { + if (event.target === overlay) closeTopChannelsMenu(listState); + }; + const onWindowResize = () => closeTopChannelsMenu(listState); + + overlay.addEventListener('click', onOverlayClick); + window.addEventListener('resize', onWindowResize); + listState.topMenuCleanup = () => { + overlay.removeEventListener('click', onOverlayClick); + window.removeEventListener('resize', onWindowResize); + }; +} + function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) { closeChannelMenu(listState, false); @@ -1060,7 +1184,11 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed container.innerHTML = ''; const allChannels = listState.channels || []; - const filtered = allChannels; + const filtered = allChannels.filter((channel) => { + if (listState.viewMode === CHANNELS_VIEW_OWNED) return channel.isOwnChannel === true; + if (listState.viewMode === CHANNELS_VIEW_FOLLOWING) return channel.sourceBucket === 'followedChannels'; + return true; + }); if (!filtered.length) { container.append(renderEmptyState()); @@ -1228,10 +1356,12 @@ export function render({ navigate, route, chrome }) { const isGuest = !state.session.isAuthorized; const listState = { openMenuId: null, + topMenuCleanup: null, notificationsState, revealedCounters: new Set(), channels: [], menuCleanup: null, + viewMode: normalizeChannelsViewMode(route), }; const contentEl = document.createElement('div'); @@ -1274,8 +1404,31 @@ export function render({ navigate, route, chrome }) { createInMyBtn.setAttribute('aria-label', 'Создать канал'); createInMyBtn.addEventListener('click', () => navigate('add-channel-view')); + const topMenuBtn = document.createElement('button'); + topMenuBtn.type = 'button'; + topMenuBtn.className = 'icon-btn channels-top-more-btn'; + topMenuBtn.setAttribute('aria-label', 'Ещё действия'); + topMenuBtn.title = 'Ещё действия'; + topMenuBtn.textContent = '⋮'; + topMenuBtn.addEventListener('click', (event) => { + event.stopPropagation(); + animatePress(topMenuBtn); + openTopChannelsMenu({ + listState, + anchorEl: topMenuBtn, + navigate, + onFindChannel: () => openChannelFinderModal({ navigate }), + onSubscribeChannel: () => openSimpleSubscribeModal({ + kind: 'channel', + kindLabel: 'Добавить канал', + submitLabel: 'Добавить', + onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }), + }), + }); + }); + topBarLeft.append(backBtn, topTitle); - topBarRight.append(findChannelBtn, createInMyBtn); + topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn); topBarEl.append(topBarLeft, topBarRight); const bottomCta = document.createElement('button'); @@ -1284,18 +1437,9 @@ export function render({ navigate, route, chrome }) { const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate }); const rerenderList = () => { - try { - const expectedPath = '/channels'; - if (window.location.pathname !== expectedPath) { - window.history.replaceState({}, '', expectedPath); - } - } catch { - // ignore history errors - } - - const isTabEmpty = !(listState.channels || []).length; - + listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} }); closeChannelMenu(listState); + closeTopChannelsMenu(listState); renderListContent({ screen, @@ -1305,9 +1449,10 @@ export function render({ navigate, route, chrome }) { refreshFeed: reloadFeed, }); - topTitle.textContent = 'Каналы'; + topTitle.textContent = channelsViewTitle(listState.viewMode); findChannelBtn.style.display = ''; createInMyBtn.style.display = ''; + topMenuBtn.style.display = ''; if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle); updateBottomCta({ button: bottomCta }); @@ -1329,6 +1474,7 @@ export function render({ navigate, route, chrome }) { screen.cleanup = () => { closeChannelMenu(listState); + closeTopChannelsMenu(listState); appScreen?.classList.remove('channels-scroll-clean'); }; diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 7df32a6a..8452c55b 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -5065,7 +5065,8 @@ textarea.input { .channels-top-back-btn, .channels-top-add-btn, -.channels-top-search-btn { +.channels-top-search-btn, +.channels-top-more-btn { width: 36px; height: 36px; min-width: 36px; @@ -5089,6 +5090,12 @@ textarea.input { box-shadow: inset 0 0 0 1px rgba(214, 249, 255, 0.2), 0 0 18px rgba(93, 211, 255, 0.38); } +.channels-top-more-btn { + font-size: 20px; + line-height: 1; + padding-bottom: 1px; +} + .channels-search-icon { position: relative; display: block; From e8a4713f6e39e779747aa4f0986425d6b76225457e6e5d7b696f782612546201 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 12 Aug 2026 19:37:29 +0400 Subject: [PATCH 3/4] =?UTF-8?q?UI:=20=D1=81=D0=B2=D0=B0=D0=B9=D0=BF=D1=8B?= =?UTF-8?q?=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=BA=20=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/app.js | 501 ++++++++++++++++++++++- shine-UI/js/components/toolbar.js | 8 +- shine-UI/js/pages/channel-thread-view.js | 48 ++- shine-UI/js/pages/channel-view.js | 3 +- shine-UI/js/pages/notifications-view.js | 5 +- shine-UI/js/router.js | 64 ++- shine-UI/styles/components.css | 47 +++ shine-UI/styles/layout.css | 118 ++++++ 9 files changed, 770 insertions(+), 26 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 3edb3292..d31849b1 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.38 +client.version=1.5.39 server.version=1.4.12 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 7b93e921..bb1b2b17 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -1,4 +1,14 @@ -import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js'; +import { + navigate, + getRoute, + parseRouteFromPath, + PRE_AUTH_PAGES, + getSwipeNavigationTarget, + syncTrackedRouteHistory, + rememberToolbarRoute, + resetRememberedToolbarRoutes, + resolveToolbarActive, +} from './router.js'; import { renderToolbar } from './components/toolbar.js'; import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js'; import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'; @@ -163,6 +173,14 @@ const SIGNED_DM_DECRYPT_CONTEXT_POLL_MS = 50; const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000; const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim(); const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/; +const KEEP_ALIVE_ROOTS = new Set(['messages-list', 'channels-list']); +const HORIZONTAL_SWIPE_MIN_DISTANCE_PX = 72; +const HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX = 56; +const HORIZONTAL_SWIPE_DOMINANCE_RATIO = 1.35; +const HORIZONTAL_SWIPE_LOCK_DISTANCE_PX = 14; +const HORIZONTAL_SWIPE_COMMIT_RATIO = 0.32; +const HORIZONTAL_SWIPE_PREVIEW_EDGE_PX = 18; +const HORIZONTAL_SWIPE_MAX_DURATION_MS = 260; let currentCleanup = null; let pingIntervalId = null; @@ -184,6 +202,9 @@ let hiddenDmAudioUnlocked = false; let initialConnectionCompleted = false; let orientationLockInFlight = false; let currentChromeCleanup = null; +let currentMountState = null; +let activeSwipePreview = null; +const keepAliveEntries = new Map(); const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; const GUEST_ALLOWED_PAGES = new Set([ 'start-view', @@ -283,6 +304,13 @@ function createChromeController(showAppChrome) { composerNode = null; apply(); }, + suspend() { + clearSlot(topbarEl, '--topbar-height'); + clearSlot(composerEl, '--composer-height'); + }, + resume() { + apply(); + }, dispose() { disposed = true; clearSlot(topbarEl, '--topbar-height'); @@ -291,6 +319,403 @@ function createChromeController(showAppChrome) { }; } +function destroyMountState(entry) { + if (!entry) return; + if (entry.destroyed) return; + entry.destroyed = true; + try { + if (typeof entry.cleanup === 'function') { + entry.cleanup(); + } + } finally { + entry.chrome?.dispose?.(); + } +} + +function clearKeepAliveEntries() { + teardownSwipePreview({ cancelOnly: true }); + keepAliveEntries.forEach((entry) => destroyMountState(entry)); + keepAliveEntries.clear(); + resetRememberedToolbarRoutes(); + currentMountState = null; + currentCleanup = null; + currentChromeCleanup = null; +} + +function detachMountedScreen(entry) { + if (!entry) return; + entry.chrome?.suspend?.(); + if (entry.screen?.parentNode === screenEl) { + screenEl.removeChild(entry.screen); + } else { + screenEl.innerHTML = ''; + } +} + +function mountExistingEntry(entry, { showAppChrome, pageId }) { + teardownSwipePreview({ cancelOnly: true }); + screenEl.innerHTML = ''; + screenEl.append(entry.screen); + entry.chrome?.resume?.(); + currentMountState = entry; + currentCleanup = typeof entry.cleanup === 'function' ? entry.cleanup : null; + currentChromeCleanup = () => entry.chrome?.dispose?.(); + screenEl.classList.toggle('no-app-chrome', !showAppChrome); + screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); +} + +function cloneScreenForSwipe(screen) { + const clone = screen?.cloneNode?.(true); + if (!(clone instanceof Node)) return null; + return clone; +} + +function sanitizeSwipeClone(node) { + if (!(node instanceof Element)) return; + node.removeAttribute('id'); + node.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id')); +} + +function cloneSlotChildForSwipe(slotEl) { + const child = slotEl?.firstElementChild; + if (!(child instanceof Node)) return null; + const clone = child.cloneNode(true); + if (clone instanceof Element) sanitizeSwipeClone(clone); + return clone; +} + +function createSwipeFrameSlot(className, contentNode = null) { + const slot = document.createElement('div'); + slot.className = className; + if (contentNode instanceof Node) { + slot.append(contentNode); + slot.hidden = false; + } else { + slot.hidden = true; + } + return slot; +} + +function buildSwipePane({ + topbarNode = null, + screenNode = null, + composerNode = null, + screenClassName = '', + screenScrollTop = 0, +}) { + const pane = document.createElement('div'); + pane.className = 'screen-swipe-pane'; + + const topbarSlot = createSwipeFrameSlot('topbar-slot screen-swipe-slot screen-swipe-slot--topbar', topbarNode); + const screenSlot = document.createElement('main'); + screenSlot.className = `${screenClassName || 'screen-content'} screen-swipe-slot screen-swipe-slot--content`; + if (screenNode instanceof Node) { + screenSlot.append(screenNode); + } + const composerSlot = createSwipeFrameSlot('composer-slot screen-swipe-slot screen-swipe-slot--composer', composerNode); + + pane.append(topbarSlot, screenSlot, composerSlot); + requestAnimationFrame(() => { + screenSlot.scrollTop = Math.max(0, Number(screenScrollTop || 0)); + }); + return pane; +} + +function createSwipePreviewTarget(targetPath) { + const route = parseRouteFromPath(`/${String(targetPath || '').replace(/^\/+/, '')}`); + const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); + const page = routes[pageId] || routes['start-view']; + const rootPageId = resolveToolbarActive(pageId); + const cachedEntry = keepAliveEntries.get(rootPageId); + if (cachedEntry && cachedEntry.routePath === `/${String(targetPath || '').replace(/^\/+/, '')}`) { + return { + screen: cloneScreenForSwipe(cachedEntry.screen), + cleanup: null, + }; + } + + let previewTopbarNode = null; + let previewComposerNode = null; + const chrome = { + setTopbar(node = null) { + previewTopbarNode = node instanceof Node ? node : null; + }, + setComposer(node = null) { + previewComposerNode = node instanceof Node ? node : null; + }, + clear() { + previewTopbarNode = null; + previewComposerNode = null; + }, + suspend() {}, + resume() {}, + dispose() {}, + }; + const screen = page.render({ route, navigate, chrome }); + if (!(screen instanceof Node)) { + chrome.dispose(); + throw new Error('Swipe preview render returned invalid node'); + } + const cleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; + return { + screen, + topbarNode: previewTopbarNode, + composerNode: previewComposerNode, + cleanup: () => { + try { + if (typeof cleanup === 'function') cleanup(); + } finally { + chrome.dispose(); + } + }, + }; +} + +function applySwipePreviewOffset(session, revealPx) { + if (!session) return; + const width = Math.max(1, session.width); + const clamped = Math.max(0, Math.min(width, revealPx)); + session.revealPx = clamped; + + const currentX = session.direction === 'left' ? -clamped : clamped; + const targetX = session.direction === 'left' + ? width - clamped + HORIZONTAL_SWIPE_PREVIEW_EDGE_PX + : -width + clamped - HORIZONTAL_SWIPE_PREVIEW_EDGE_PX; + const dividerX = session.direction === 'left' + ? width - clamped + : clamped; + + session.currentPane.style.transform = `translate3d(${currentX}px, 0, 0)`; + session.targetPane.style.transform = `translate3d(${targetX}px, 0, 0)`; + session.divider.style.transform = `translate3d(${dividerX}px, 0, 0)`; + + const overlayOpacity = Math.max(0.08, Math.min(0.24, (clamped / width) * 0.24)); + session.overlay.style.setProperty('--swipe-overlay-opacity', overlayOpacity.toFixed(3)); +} + +function teardownSwipePreview({ cancelOnly = false } = {}) { + const session = activeSwipePreview; + if (!session) return; + activeSwipePreview = null; + + appShellEl?.classList.remove('app-shell--swiping'); + topbarEl?.classList.remove('topbar-slot--swipe-hidden'); + screenEl.classList.remove('screen-content--swipe-hidden'); + composerEl?.classList.remove('composer-slot--swipe-hidden'); + session.overlay.remove(); + if (typeof session.targetCleanup === 'function') { + session.targetCleanup(); + } + if (!cancelOnly) { + session.onComplete?.(); + } +} + +function animateSwipePreviewTo(session, revealPx, { complete = false } = {}) { + const width = Math.max(1, session.width); + const currentReveal = Number(session.revealPx || 0); + const remaining = Math.abs(revealPx - currentReveal); + const duration = Math.max(140, Math.min(HORIZONTAL_SWIPE_MAX_DURATION_MS, Math.round((remaining / width) * HORIZONTAL_SWIPE_MAX_DURATION_MS))); + + [session.currentPane, session.targetPane, session.divider].forEach((node) => { + node.style.transition = `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`; + }); + session.overlay.style.transition = `opacity ${duration}ms ease`; + + requestAnimationFrame(() => { + applySwipePreviewOffset(session, revealPx); + if (!complete) { + session.overlay.style.opacity = '0'; + } + }); + + window.setTimeout(() => { + teardownSwipePreview({ cancelOnly: !complete }); + }, duration + 24); +} + +function beginSwipePreview(direction, targetPath) { + if (!currentMountState?.screen || activeSwipePreview) return null; + const currentTopbarClone = cloneSlotChildForSwipe(topbarEl); + const currentScreenClone = cloneScreenForSwipe(currentMountState.screen); + const currentComposerClone = cloneSlotChildForSwipe(composerEl); + if (!(currentScreenClone instanceof Node)) return null; + if (currentTopbarClone instanceof Element) sanitizeSwipeClone(currentTopbarClone); + if (currentScreenClone instanceof Element) sanitizeSwipeClone(currentScreenClone); + if (currentComposerClone instanceof Element) sanitizeSwipeClone(currentComposerClone); + + const targetPreview = createSwipePreviewTarget(targetPath); + if (!(targetPreview?.screen instanceof Node)) { + targetPreview?.cleanup?.(); + return null; + } + if (targetPreview.topbarNode instanceof Element) sanitizeSwipeClone(targetPreview.topbarNode); + if (targetPreview.screen instanceof Element) sanitizeSwipeClone(targetPreview.screen); + if (targetPreview.composerNode instanceof Element) sanitizeSwipeClone(targetPreview.composerNode); + + const overlay = document.createElement('div'); + overlay.className = 'screen-swipe-overlay'; + + const currentPane = buildSwipePane({ + topbarNode: currentTopbarClone, + screenNode: currentScreenClone, + composerNode: currentComposerClone, + screenClassName: screenEl.className, + screenScrollTop: screenEl.scrollTop, + }); + currentPane.classList.add('screen-swipe-pane--current'); + + const targetPane = buildSwipePane({ + topbarNode: targetPreview.topbarNode || null, + screenNode: targetPreview.screen, + composerNode: targetPreview.composerNode || null, + screenClassName: screenEl.className, + screenScrollTop: 0, + }); + targetPane.classList.add('screen-swipe-pane--target', `screen-swipe-pane--${direction}`); + + const divider = document.createElement('div'); + divider.className = 'screen-swipe-divider'; + + overlay.append(currentPane, targetPane, divider); + appShellEl.append(overlay); + appShellEl?.classList.add('app-shell--swiping'); + topbarEl?.classList.add('topbar-slot--swipe-hidden'); + screenEl.classList.add('screen-content--swipe-hidden'); + composerEl?.classList.add('composer-slot--swipe-hidden'); + + const session = { + direction, + targetPath, + width: screenEl.clientWidth || 1, + overlay, + currentPane, + targetPane, + divider, + targetCleanup: targetPreview.cleanup || null, + revealPx: 0, + onComplete: () => navigate(targetPath), + }; + activeSwipePreview = session; + applySwipePreviewOffset(session, 0); + return session; +} + +function installHorizontalTabSwipe() { + if (!screenEl) return; + + let touchStartX = 0; + let touchStartY = 0; + let touchActive = false; + let touchBlocked = false; + let swipeLocked = false; + let swipeDirection = ''; + let swipeTargetPath = ''; + let swipeSession = null; + + const reset = () => { + touchActive = false; + touchBlocked = false; + swipeLocked = false; + swipeDirection = ''; + swipeTargetPath = ''; + swipeSession = null; + touchStartX = 0; + touchStartY = 0; + }; + + screenEl.addEventListener('touchstart', (event) => { + if (event.touches.length !== 1) { + reset(); + return; + } + const target = event.target instanceof Element ? event.target : null; + touchBlocked = Boolean(target?.closest('input, textarea, select, button, a, [contenteditable="true"]')); + touchActive = !touchBlocked; + touchStartX = Number(event.touches[0]?.clientX || 0); + touchStartY = Number(event.touches[0]?.clientY || 0); + }, { passive: true }); + + screenEl.addEventListener('touchmove', (event) => { + if (!touchActive || touchBlocked) return; + const touch = event.touches?.[0]; + const deltaX = Number(touch?.clientX || 0) - touchStartX; + const deltaY = Number(touch?.clientY || 0) - touchStartY; + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + + if (!swipeLocked) { + if (absX < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX && absY < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX) return; + if (absX <= absY * 1.05) { + touchBlocked = true; + return; + } + const currentPageId = getRoute().pageId || ''; + swipeDirection = deltaX < 0 ? 'left' : 'right'; + swipeTargetPath = getSwipeNavigationTarget(currentPageId, swipeDirection); + if (!swipeTargetPath) { + touchBlocked = true; + return; + } + swipeSession = beginSwipePreview(swipeDirection, swipeTargetPath); + if (!swipeSession) { + touchBlocked = true; + return; + } + swipeLocked = true; + } + + if (!swipeLocked || !swipeSession) return; + event.preventDefault(); + + const revealPx = swipeDirection === 'left' + ? Math.max(0, -deltaX) + : Math.max(0, deltaX); + applySwipePreviewOffset(swipeSession, revealPx); + }, { passive: false }); + + screenEl.addEventListener('touchcancel', reset, { passive: true }); + + screenEl.addEventListener('touchend', (event) => { + if (!touchActive || touchBlocked) { + if (swipeSession) { + animateSwipePreviewTo(swipeSession, 0, { complete: false }); + } + reset(); + return; + } + + const touch = event.changedTouches?.[0]; + const endX = Number(touch?.clientX || 0); + const endY = Number(touch?.clientY || 0); + const deltaX = endX - touchStartX; + const deltaY = endY - touchStartY; + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + const session = swipeSession; + const wasLocked = swipeLocked; + reset(); + + if (wasLocked && session) { + event.preventDefault(); + const revealRatio = Number(session.revealPx || 0) / Math.max(1, session.width); + const shouldCommit = revealRatio >= HORIZONTAL_SWIPE_COMMIT_RATIO; + animateSwipePreviewTo(session, shouldCommit ? session.width : 0, { complete: shouldCommit }); + return; + } + + if (absX < HORIZONTAL_SWIPE_MIN_DISTANCE_PX) return; + if (absY > HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX) return; + if (absX <= absY * HORIZONTAL_SWIPE_DOMINANCE_RATIO) return; + + const currentPageId = getRoute().pageId || ''; + const direction = deltaX < 0 ? 'left' : 'right'; + const target = getSwipeNavigationTarget(currentPageId, direction); + if (!target) return; + navigate(target); + }, { passive: true }); +} + async function unlockHiddenDmAudio() { try { const Ctx = window.AudioContext || window.webkitAudioContext; @@ -971,6 +1396,7 @@ function renderPageFailureFallback(pageId, error) { }); screenEl.innerHTML = ''; + teardownSwipePreview({ cancelOnly: true }); const wrap = document.createElement('section'); wrap.className = 'stack'; @@ -1007,6 +1433,8 @@ function renderPageFailureFallback(pageId, error) { } function renderApp() { + teardownSwipePreview({ cancelOnly: true }); + syncTrackedRouteHistory(window.location.pathname || '/'); const route = getRoute(); const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); @@ -1021,19 +1449,61 @@ function renderApp() { } const page = routes[pageId] || routes['start-view']; + const showAppChrome = page.pageMeta?.showAppChrome !== false; + const rootPageId = resolveToolbarActive(pageId); + const keepAliveEligible = showAppChrome && KEEP_ALIVE_ROOTS.has(rootPageId); + const currentRoutePath = String(window.location.pathname || '/'); - if (typeof currentCleanup === 'function') { - currentCleanup(); - currentCleanup = null; + rememberToolbarRoute(pageId); + + if (currentMountState) { + const shouldPreserveCurrent = currentMountState.keepAlive && currentMountState.rootPageId !== rootPageId; + if (shouldPreserveCurrent) { + currentMountState.routePath = currentMountState.routePath || currentRoutePath; + keepAliveEntries.set(currentMountState.rootPageId, currentMountState); + detachMountedScreen(currentMountState); + currentMountState = null; + currentCleanup = null; + currentChromeCleanup = null; + } else { + destroyMountState(currentMountState); + if (currentMountState.keepAlive) { + keepAliveEntries.delete(currentMountState.rootPageId); + } + currentMountState = null; + currentCleanup = null; + currentChromeCleanup = null; + } + } else { + if (typeof currentCleanup === 'function') { + currentCleanup(); + currentCleanup = null; + } + if (typeof currentChromeCleanup === 'function') { + currentChromeCleanup(); + currentChromeCleanup = null; + } } - if (typeof currentChromeCleanup === 'function') { - currentChromeCleanup(); - currentChromeCleanup = null; + + const cachedEntry = keepAliveEligible ? keepAliveEntries.get(rootPageId) : null; + if (cachedEntry && cachedEntry.routePath === currentRoutePath) { + mountExistingEntry(cachedEntry, { showAppChrome, pageId }); + toolbarEl.innerHTML = ''; + if (showAppChrome) { + toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); + } + toolbarHeightObserver?.sync?.(); + refreshConnectionUi(); + return; + } + + if (cachedEntry) { + destroyMountState(cachedEntry); + keepAliveEntries.delete(rootPageId); } try { screenEl.innerHTML = ''; - const showAppChrome = page.pageMeta?.showAppChrome !== false; const chrome = createChromeController(showAppChrome); currentChromeCleanup = () => chrome.dispose(); const screen = page.render({ route, navigate, chrome }); @@ -1043,6 +1513,19 @@ function renderApp() { screenEl.append(screen); currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; + currentMountState = { + pageId, + rootPageId, + keepAlive: keepAliveEligible, + routePath: currentRoutePath, + screen, + cleanup: currentCleanup, + chrome, + destroyed: false, + }; + if (keepAliveEligible) { + keepAliveEntries.set(rootPageId, currentMountState); + } screenEl.classList.toggle('no-app-chrome', !showAppChrome); screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); @@ -1163,6 +1646,7 @@ async function init() { setSessionResetHandler(() => { sessionRuntimeStarted = false; startConnectionMonitor(); + clearKeepAliveEntries(); if (reconnectIntervalId) { window.clearInterval(reconnectIntervalId); reconnectIntervalId = null; @@ -1524,6 +2008,7 @@ async function init() { })(); window.addEventListener('popstate', renderApp); + installHorizontalTabSwipe(); document.addEventListener('pointerdown', () => { void unlockHiddenDmAudio(); }, { passive: true }); diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js index 38f15286..3f75b495 100644 --- a/shine-UI/js/components/toolbar.js +++ b/shine-UI/js/components/toolbar.js @@ -1,4 +1,4 @@ -import { resolveToolbarActive } from '../router.js'; +import { getToolbarNavigationTarget, resolveToolbarActive } from '../router.js'; import { state } from '../state.js'; import { openAuthRequiredModal } from '../services/auth-required-modal.js'; @@ -33,7 +33,7 @@ function getTotalUnreadMessages() { function navigateWithGuestRules(pageId, navigate) { if (state.session.isAuthorized) { - navigate(pageId); + navigate(getToolbarNavigationTarget(pageId)); return; } if (pageId === 'messages-list') { @@ -57,7 +57,7 @@ function navigateWithGuestRules(pageId, navigate) { }); return; } - navigate(pageId); + navigate(getToolbarNavigationTarget(pageId)); } export function renderToolbar(currentPageId, navigate) { @@ -93,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) { btn.append(badge); } if (item.pageId === 'channels-list') { - btn.addEventListener('click', () => navigate('channels-list')); + btn.addEventListener('click', () => navigate(getToolbarNavigationTarget('channels-list'))); } else { btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate)); } diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 8d68578d..f8a1cd56 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -10,7 +10,7 @@ import { showToast, softHaptic, } from '../services/channels-ux.js'; -import { navigateBack } from '../router.js'; +import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { @@ -332,6 +332,25 @@ function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') { }); } +function resolveThreadBackRoute(selector, resolvedChannelLabel = '') { + return buildChannelRouteFromThread(selector, resolvedChannelLabel) || 'channels-list'; +} + +function resolveThreadPreviousInChannels(selector, resolvedChannelLabel = '') { + const previousPath = String(getPreviousTrackedPath() || '').trim(); + if (previousPath) { + const previousRoute = parseRouteFromPath(previousPath); + const previousPageId = String(previousRoute?.pageId || '').trim(); + if ( + previousPageId === 'channel-thread-view' + || previousPageId === 'channel-view' + ) { + return previousPath; + } + } + return resolveThreadBackRoute(selector, resolvedChannelLabel); +} + function buildTargetFromNode(node) { const blockchainName = String(node?.authorBlockchainName || '').trim(); const blockNumber = Number(node?.messageRef?.blockNumber); @@ -1118,6 +1137,7 @@ export function render({ navigate, route, chrome }) { const selector = parseThreadSelector(route); const channelDisplayName = resolveChannelDisplayName(selector?.channel); const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`; + let activeResolvedChannelLabel = channelDisplayName; const screen = document.createElement('section'); screen.className = 'stack channels-screen channels-screen--thread'; @@ -1126,13 +1146,28 @@ export function render({ navigate, route, chrome }) { const header = renderHeader({ title: '', - leftAction: { label: '<', onClick: () => navigateBack() }, - rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }], + leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) }, + rightActions: [], }); - const threadHeaderButton = header.querySelector('.header-actions .icon-btn'); - if (threadHeaderButton) { - threadHeaderButton.classList.add('channel-header-route-btn'); + header.classList.add('channel-thread-topbar'); + const headerLeft = header.querySelector('.header-left'); + let threadHeaderButton = null; + if (headerLeft) { + const channelsListButton = document.createElement('button'); + channelsListButton.type = 'button'; + channelsListButton.className = 'icon-btn'; + channelsListButton.textContent = '↑'; + channelsListButton.title = 'К списку каналов'; + channelsListButton.setAttribute('aria-label', 'К списку каналов'); + channelsListButton.addEventListener('click', () => navigate('channels-list')); + headerLeft.append(channelsListButton); + + threadHeaderButton = document.createElement('button'); + threadHeaderButton.type = 'button'; + threadHeaderButton.className = 'icon-btn channel-header-route-btn'; + threadHeaderButton.textContent = 'Тред в канале: ...'; threadHeaderButton.disabled = true; + headerLeft.append(threadHeaderButton); } chrome?.setTopbar(header); @@ -1418,6 +1453,7 @@ export function render({ navigate, route, chrome }) { if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) { resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel); } + activeResolvedChannelLabel = resolvedChannelLabel; const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно'; const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel; if (threadHeaderButton) { diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 3ed0af61..9344210d 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -16,7 +16,6 @@ import { showToast, softHaptic, } from '../services/channels-ux.js'; -import { navigateBack } from '../router.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { @@ -2036,7 +2035,7 @@ export function render({ navigate, route, chrome }) { const header = renderHeader({ title: '', - leftAction: { label: '<', onClick: () => navigateBack() }, + leftAction: { label: '<', onClick: () => navigate('channels-list') }, rightActions: [ { label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} }, { label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} }, diff --git a/shine-UI/js/pages/notifications-view.js b/shine-UI/js/pages/notifications-view.js index 3aed5941..27426c7e 100644 --- a/shine-UI/js/pages/notifications-view.js +++ b/shine-UI/js/pages/notifications-view.js @@ -26,11 +26,10 @@ function renderList(container) { container.append(card); } -export function render() { +export function render({ chrome } = {}) { const screen = document.createElement('section'); screen.className = 'stack notifications-screen'; - - screen.append(renderHeader({ title: 'Уведомления' })); + chrome?.setTopbar(renderHeader({ title: 'Уведомления' })); const tabs = document.createElement('div'); tabs.className = 'tabs'; diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index e251792b..0d0540e0 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -1,6 +1,10 @@ import { parseShineRouteParts } from './services/shine-routes.js'; const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view']; +const SWIPEABLE_ROOT_PAGES = ['messages-list', 'channels-list', 'notifications-view', 'profile-view']; +const lastVisitedRouteByRoot = new Map(); +let previousTrackedPath = ''; +let currentTrackedPath = String(window.location.pathname || '').trim() || '/'; const PRETTY_PATHS = new Map([ ['start-view', 'start'], ['entry-settings-view', 'entry-settings'], @@ -68,8 +72,8 @@ export const PRE_AUTH_PAGES = [ 'key-storage-view', ]; -export function getRoute() { - const currentPath = String(window.location.pathname || '').trim(); +export function parseRouteFromPath(pathname = '') { + const currentPath = String(pathname || '').trim(); const raw = currentPath .replace(/^\/+/, '') .replace(/^index\.html$/i, '') @@ -344,15 +348,29 @@ export function getRoute() { return { pageId, params: {} }; } +export function getRoute() { + return parseRouteFromPath(window.location.pathname || ''); +} + export function navigate(path) { const cleanPath = toPrettyPath(path); const nextPath = cleanPath ? `/${cleanPath}` : '/'; + if (window.location.pathname !== nextPath) { + previousTrackedPath = currentTrackedPath; + currentTrackedPath = nextPath; + } if (window.location.pathname !== nextPath) { window.history.pushState({}, '', nextPath); } window.dispatchEvent(new PopStateEvent('popstate')); } +function normalizeCurrentPath() { + return String(window.location.pathname || '') + .replace(/^\/+/, '') + .replace(/\/+$/, ''); +} + export function toPrettyPath(path) { const raw = String(path || '').replace(/^\/+/, '').replace(/\/+$/, ''); if (!raw) return ''; @@ -397,3 +415,45 @@ export function resolveToolbarActive(pageId) { if (pageId === 'user') return 'messages-list'; return 'profile-view'; } + +export function rememberToolbarRoute(pageId, explicitPath = '') { + const rootPageId = resolveToolbarActive(pageId); + if (!ROOT_PAGES.includes(rootPageId)) return; + const cleanPath = String(explicitPath || normalizeCurrentPath()).trim(); + lastVisitedRouteByRoot.set(rootPageId, cleanPath || toPrettyPath(rootPageId) || rootPageId); +} + +export function getToolbarNavigationTarget(pageId) { + const rootPageId = resolveToolbarActive(pageId); + return lastVisitedRouteByRoot.get(rootPageId) || toPrettyPath(rootPageId) || rootPageId; +} + +export function resetRememberedToolbarRoutes() { + lastVisitedRouteByRoot.clear(); + previousTrackedPath = ''; + currentTrackedPath = String(window.location.pathname || '').trim() || '/'; +} + +export function getSwipeNavigationTarget(currentPageId, direction) { + const rootPageId = resolveToolbarActive(currentPageId); + const index = SWIPEABLE_ROOT_PAGES.indexOf(rootPageId); + if (index === -1) return ''; + + const step = direction === 'left' ? 1 : direction === 'right' ? -1 : 0; + if (!step) return ''; + + const nextIndex = index + step; + if (nextIndex < 0 || nextIndex >= SWIPEABLE_ROOT_PAGES.length) return ''; + return getToolbarNavigationTarget(SWIPEABLE_ROOT_PAGES[nextIndex]); +} + +export function syncTrackedRouteHistory(pathname = '') { + const nextPath = String(pathname || '').trim() || '/'; + if (nextPath === currentTrackedPath) return; + previousTrackedPath = currentTrackedPath; + currentTrackedPath = nextPath; +} + +export function getPreviousTrackedPath() { + return previousTrackedPath; +} diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 8452c55b..4d85613a 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -17,6 +17,8 @@ .topbar-slot .dm-head, .topbar-slot .channels-top-bar { margin-bottom: 0; + min-height: calc(68px + env(safe-area-inset-top)); + box-sizing: border-box; border-radius: 18px; background: rgba(14, 21, 35, 0.92); border: 1px solid rgba(212, 175, 55, 0.18); @@ -30,10 +32,22 @@ .topbar-slot .dm-head, .topbar-slot .channels-top-bar { + min-height: calc(68px + env(safe-area-inset-top)); padding-top: calc(10px + env(safe-area-inset-top)); padding-bottom: 10px; } +.topbar-slot .dm-head { + position: relative; + top: auto; +} + +.topbar-slot .page-header, +.topbar-slot .dm-head, +.topbar-slot .channels-top-bar { + align-items: center; +} + .app-topbar-shell .page-title { flex: 1 1 auto; text-align: center; @@ -5759,6 +5773,25 @@ textarea.input { font-weight: 700; } +.channels-screen--thread .page-header.channel-thread-topbar { + display: flex; + align-items: center; + gap: 8px; +} + +.channels-screen--thread .page-header.channel-thread-topbar .page-title, +.channels-screen--thread .page-header.channel-thread-topbar .header-actions { + display: none; +} + +.channels-screen--thread .page-header.channel-thread-topbar .header-left { + min-width: 0; + width: 100%; + flex: 1 1 auto; + gap: 8px; + flex-wrap: nowrap; +} + .channels-screen--channel .page-header .icon-btn, .channels-screen--thread .page-header .icon-btn { border: none; @@ -5768,6 +5801,11 @@ textarea.input { transform: translateY(-40%); } +.channels-screen--thread .page-header.channel-thread-topbar .icon-btn { + transform: none; + flex: 0 0 auto; +} + .channels-screen--channel .page-header .channel-header-route-btn, .channels-screen--thread .page-header .channel-header-route-btn { border: 1px solid rgba(146, 173, 229, 0.38); @@ -5775,6 +5813,15 @@ textarea.input { color: #d9e6ff; } +.channels-screen--thread .page-header.channel-thread-topbar .channel-header-route-btn { + min-width: 0; + flex: 1 1 auto; + justify-content: flex-start; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .channels-screen--channel .page-header .channel-header-entrypoint-btn { border: 1px solid rgba(224, 190, 117, 0.38); background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34)); diff --git a/shine-UI/styles/layout.css b/shine-UI/styles/layout.css index 9f33842b..921603f0 100644 --- a/shine-UI/styles/layout.css +++ b/shine-UI/styles/layout.css @@ -59,6 +59,124 @@ body::before { padding: 14px 14px 24px; } +.screen-swipe-overlay { + --swipe-overlay-opacity: 0.12; + position: absolute; + top: var(--call-minimized-bar-height, 0px); + left: 0; + right: 0; + bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom)); + z-index: 8; + overflow: hidden; + pointer-events: none; + opacity: 1; +} + +.screen-swipe-overlay::before { + content: ''; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(5, 10, 20, 0.06) 0%, rgba(5, 10, 20, 0.16) 100%), + radial-gradient(circle at center, rgba(89, 165, 255, 0.06) 0%, transparent 70%); + opacity: var(--swipe-overlay-opacity, 0.12); +} + +.screen-swipe-pane { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: hidden; + backface-visibility: hidden; + will-change: transform; +} + +.screen-swipe-pane--current { + left: 0; + z-index: 1; +} + +.screen-swipe-pane--target { + left: 0; + z-index: 2; + filter: saturate(1.03); +} + +.screen-swipe-slot { + position: absolute; + left: 0; + right: 0; +} + +.screen-swipe-slot--topbar { + top: 0; + padding: 0 12px; + pointer-events: none; +} + +.screen-swipe-slot--topbar > * { + pointer-events: none; +} + +.screen-swipe-slot--content { + top: calc(var(--topbar-height, 0px)); + bottom: var(--composer-height, 0px); + overflow-y: auto; + padding: 14px 14px 24px; +} + +.screen-swipe-slot--composer { + bottom: 0; + padding: 0 12px 8px; + pointer-events: none; +} + +.screen-swipe-slot--composer > * { + pointer-events: none; +} + +.screen-swipe-pane--target::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(8, 14, 28, 0.05) 0%, rgba(8, 14, 28, 0.12) 100%); + pointer-events: none; +} + +.screen-swipe-pane--left { + box-shadow: -24px 0 34px rgba(3, 7, 18, 0.34); +} + +.screen-swipe-pane--right { + box-shadow: 24px 0 34px rgba(3, 7, 18, 0.34); +} + +.screen-swipe-divider { + position: absolute; + top: 0; + bottom: 0; + left: -1px; + width: 2px; + z-index: 3; + background: + linear-gradient(180deg, rgba(248, 251, 255, 0.68) 0%, rgba(137, 186, 255, 0.52) 48%, rgba(53, 90, 144, 0.34) 100%); + box-shadow: + 0 0 0 1px rgba(14, 24, 42, 0.08), + 0 0 18px rgba(80, 154, 255, 0.22); + pointer-events: none; +} + +.app-shell--swiping .toolbar-slot { + pointer-events: none; +} + +.topbar-slot--swipe-hidden, +.screen-content--swipe-hidden, +.composer-slot--swipe-hidden { + opacity: 0; +} + .screen-content.no-app-chrome { top: var(--call-minimized-bar-height, 0px); bottom: 0; From ce30b77328be9afbf1990aa8a04e0cba3bc1c5b3b49267c40d2858201f5cab2c Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 19 Aug 2026 14:30:29 +0400 Subject: [PATCH 4/4] =?UTF-8?q?=D0=A1=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=20=D0=B8=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20UI=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/shine/db/DatabaseInitializer.java | 24 + .../java/shine/db/dao/CurrentUsersDAO.java | 8 +- .../java/shine/db/sql/CurrentUsersSql.java | 1 + .../main/resources/postgres/migration_v5.sql | 83 + .../main/resources/postgres/migration_v6.sql | 21 + .../main/resources/postgres/migration_v7.sql | 16 + .../main/resources/postgres/migration_v8.sql | 16 + .../src/main/resources/postgres/schema_v1.sql | 21 +- .../postgres/PostgresStorageRepository.java | 88 +- VERSION.properties | 4 +- deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md | 5 +- .../Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md | 8 + shine-UI/js/app.js | 479 +-- .../components/arweave-attachment-manager.js | 78 +- shine-UI/js/components/toolbar.js | 8 +- shine-UI/js/pages/channels-list.js | 2 +- shine-UI/js/pages/wallet-view.js | 513 ++- shine-UI/js/router.js | 48 +- shine-UI/js/services/auth-service.js | 69 +- shine-UI/js/services/key-vault.js | 21 +- .../shine-blockchain-snapshot-service.js | 108 + .../js/services/shine-user-pda-service.js | 31 +- shine-UI/styles/layout.css | 118 - shine-solana-arweave-viewer/README.md | 11 + .../history/v1/README.md | 11 + .../history/v1/index.html | 2358 ++++++++++++ .../history/v1/КАК_ЭТО_РАБОТАЕТ.md | 114 + shine-solana-arweave-viewer/index.html | 3166 +++++++++++++++++ .../КАК_ЭТО_РАБОТАЕТ.md | 114 + 29 files changed, 6729 insertions(+), 815 deletions(-) create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v5.sql create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v6.sql create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v7.sql create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v8.sql create mode 100644 shine-UI/js/services/shine-blockchain-snapshot-service.js create mode 100644 shine-solana-arweave-viewer/README.md create mode 100644 shine-solana-arweave-viewer/history/v1/README.md create mode 100644 shine-solana-arweave-viewer/history/v1/index.html create mode 100644 shine-solana-arweave-viewer/history/v1/КАК_ЭТО_РАБОТАЕТ.md create mode 100644 shine-solana-arweave-viewer/index.html create mode 100644 shine-solana-arweave-viewer/КАК_ЭТО_РАБОТАЕТ.md diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 793c49a1..ea9f4932 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -21,10 +21,18 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_2 = 2; public static final int SCHEMA_VERSION_3 = 3; public static final int SCHEMA_VERSION_4 = 4; + public static final int SCHEMA_VERSION_5 = 5; + public static final int SCHEMA_VERSION_6 = 6; + public static final int SCHEMA_VERSION_7 = 7; + public static final int SCHEMA_VERSION_8 = 8; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql"; + public static final String POSTGRES_MIGRATION_V5_RESOURCE = "postgres/migration_v5.sql"; + public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql"; + public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql"; + public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql"; private DatabaseInitializer() {} @@ -100,6 +108,22 @@ public final class DatabaseInitializer { } if (currentVersion < SCHEMA_VERSION_4) { runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE); + currentVersion = SCHEMA_VERSION_4; + } + if (currentVersion < SCHEMA_VERSION_5) { + runSqlScript(conn, POSTGRES_MIGRATION_V5_RESOURCE); + currentVersion = SCHEMA_VERSION_5; + } + if (currentVersion < SCHEMA_VERSION_6) { + runSqlScript(conn, POSTGRES_MIGRATION_V6_RESOURCE); + currentVersion = SCHEMA_VERSION_6; + } + if (currentVersion < SCHEMA_VERSION_7) { + runSqlScript(conn, POSTGRES_MIGRATION_V7_RESOURCE); + currentVersion = SCHEMA_VERSION_7; + } + if (currentVersion < SCHEMA_VERSION_8) { + runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE); } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java index ec61b084..33d47ff7 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java @@ -49,7 +49,7 @@ public final class CurrentUsersDAO { String sql = """ SELECT 1 FROM %s - WHERE LOWER(login) = LOWER(?) + WHERE normalized_login = LOWER(BTRIM(?)) LIMIT 1 """.formatted(CurrentUsersSql.usersSubquery("su")); @@ -104,7 +104,7 @@ public final class CurrentUsersDAO { blockchain_key, client_key FROM %s - WHERE LOWER(login) = LOWER(?) + WHERE normalized_login = LOWER(BTRIM(?)) """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -167,7 +167,7 @@ public final class CurrentUsersDAO { blockchain_key, client_key FROM %s - WHERE LOWER(login) LIKE ? + WHERE normalized_login LIKE LOWER(BTRIM(?)) AND (? IS NULL OR is_server = ?) ORDER BY login LIMIT 5 @@ -176,7 +176,7 @@ public final class CurrentUsersDAO { List result = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, prefix.toLowerCase() + "%"); + ps.setString(1, prefix.trim() + "%"); if (isServer == null) { ps.setNull(2, Types.BOOLEAN); ps.setNull(3, Types.BOOLEAN); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java index 837e5aec..8fc33d91 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java @@ -12,6 +12,7 @@ public final class CurrentUsersSql { ( SELECT current_users.login AS login, + current_users.normalized_login AS normalized_login, current_users.blockchain_name AS blockchain_name, current_users.client_key AS solana_key, current_users.blockchain_key AS blockchain_key, diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v5.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v5.sql new file mode 100644 index 00000000..822a5483 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v5.sql @@ -0,0 +1,83 @@ +BEGIN; + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS normalized_login TEXT; + +UPDATE solana_user_pda_current +SET normalized_login = LOWER(BTRIM(login)) +WHERE normalized_login IS NULL + OR normalized_login <> LOWER(BTRIM(login)); + +ALTER TABLE solana_user_pda_current + ALTER COLUMN normalized_login SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login + ON solana_user_pda_current(normalized_login); + +CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login + ON solana_user_pda_current(normalized_login); + +WITH bad_signed_messages AS ( + SELECT DISTINCT sm.message_key + FROM signed_messages sm + LEFT JOIN solana_user_pda_current u_from + ON u_from.normalized_login = LOWER(BTRIM(sm.from_login)) + LEFT JOIN solana_user_pda_current u_to + ON u_to.normalized_login = LOWER(BTRIM(sm.to_login)) + WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login) + OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login) +) +DELETE FROM signed_message_session_delivery d +USING bad_signed_messages bad +WHERE d.message_key = bad.message_key; + +WITH bad_signed_messages AS ( + SELECT DISTINCT sm.message_key + FROM signed_messages sm + LEFT JOIN solana_user_pda_current u_from + ON u_from.normalized_login = LOWER(BTRIM(sm.from_login)) + LEFT JOIN solana_user_pda_current u_to + ON u_to.normalized_login = LOWER(BTRIM(sm.to_login)) + WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login) + OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login) +) +DELETE FROM signed_messages sm +USING bad_signed_messages bad +WHERE sm.message_key = bad.message_key; + +DELETE FROM signed_direct_messages_history h +USING solana_user_pda_current u_from, + solana_user_pda_current u_to +WHERE u_from.normalized_login = LOWER(BTRIM(h.from_login)) + AND u_to.normalized_login = LOWER(BTRIM(h.to_login)) + AND (h.from_login <> u_from.login OR h.to_login <> u_to.login); + +DELETE FROM signed_direct_message_replay r +USING solana_user_pda_current u_from +WHERE u_from.normalized_login = LOWER(BTRIM(r.from_login)) + AND r.from_login <> u_from.login; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'direct_messages' + ) THEN + DELETE FROM direct_messages d + USING solana_user_pda_current u_from, + solana_user_pda_current u_to + WHERE u_from.normalized_login = LOWER(BTRIM(d.from_login)) + AND u_to.normalized_login = LOWER(BTRIM(d.to_login)) + AND (d.from_login <> u_from.login OR d.to_login <> u_to.login); + END IF; +END $$; + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 5, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v6.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v6.sql new file mode 100644 index 00000000..7407353a --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v6.sql @@ -0,0 +1,21 @@ +BEGIN; + +ALTER TABLE signed_messages + DROP CONSTRAINT IF EXISTS signed_messages_from_login_fkey; + +ALTER TABLE signed_messages + DROP CONSTRAINT IF EXISTS signed_messages_to_login_fkey; + +ALTER TABLE signed_direct_messages_history + DROP CONSTRAINT IF EXISTS signed_direct_messages_history_from_login_fkey; + +ALTER TABLE signed_direct_messages_history + DROP CONSTRAINT IF EXISTS signed_direct_messages_history_to_login_fkey; + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 6, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v7.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v7.sql new file mode 100644 index 00000000..86017a32 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v7.sql @@ -0,0 +1,16 @@ +BEGIN; + +ALTER TABLE blocks + DROP CONSTRAINT IF EXISTS blocks_login_fkey; + +ALTER TABLE blocks + ADD CONSTRAINT blocks_login_fkey + FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 7, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v8.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v8.sql new file mode 100644 index 00000000..c28701f2 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v8.sql @@ -0,0 +1,16 @@ +BEGIN; + +ALTER TABLE connections_state + DROP CONSTRAINT IF EXISTS connections_state_login_fkey; + +ALTER TABLE connections_state + ADD CONSTRAINT connections_state_login_fkey + FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 8, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 5778d7fe..b86bd001 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version ( ); INSERT INTO db_schema_version (id, schema_version, updated_at_ms) -VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +VALUES (1, 8, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) ON CONFLICT (id) DO UPDATE SET schema_version = EXCLUDED.schema_version, updated_at_ms = EXCLUDED.updated_at_ms; @@ -74,6 +74,7 @@ CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login CREATE TABLE IF NOT EXISTS solana_user_pda_current ( pda_address TEXT PRIMARY KEY, login TEXT NOT NULL UNIQUE, + normalized_login TEXT NOT NULL, record_number INTEGER NOT NULL, slot BIGINT NOT NULL, last_tx_signature TEXT NOT NULL, @@ -109,6 +110,12 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current ( CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot ON solana_user_pda_current(slot); +CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login + ON solana_user_pda_current(normalized_login); + +CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login + ON solana_user_pda_current(normalized_login); + CREATE TABLE IF NOT EXISTS user_access_servers_current ( user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, @@ -440,7 +447,7 @@ CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at ON blockchain_state(updated_at_ms); CREATE TABLE IF NOT EXISTS blocks ( - login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login), bch_name TEXT NOT NULL REFERENCES blockchain_state(blockchain_name), block_number INTEGER NOT NULL CHECK (block_number >= 0), msg_type INTEGER NOT NULL, @@ -470,7 +477,7 @@ CREATE INDEX IF NOT EXISTS idx_blocks_by_line ON blocks (bch_name, line_code, this_line_number); CREATE TABLE IF NOT EXISTS connections_state ( - login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login), rel_type INTEGER NOT NULL, to_login TEXT NOT NULL, to_bch_name TEXT NOT NULL, @@ -624,8 +631,8 @@ CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created CREATE TABLE IF NOT EXISTS signed_direct_messages_history ( message_id TEXT PRIMARY KEY, - from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), - to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + from_login TEXT NOT NULL, + to_login TEXT NOT NULL, target_mode INTEGER NOT NULL, target_session_id TEXT, message_type INTEGER NOT NULL, @@ -642,8 +649,8 @@ CREATE TABLE IF NOT EXISTS signed_messages ( message_key TEXT PRIMARY KEY, base_key TEXT NOT NULL, target_login TEXT NOT NULL, - from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), - to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + from_login TEXT NOT NULL, + to_login TEXT NOT NULL, time_ms BIGINT NOT NULL, nonce BIGINT NOT NULL, message_type INTEGER NOT NULL, diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java index b1a2c145..2eddfe75 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -8,6 +8,7 @@ import sync.codec.ShineUsersCodec; import java.sql.*; import java.util.*; +import java.util.Locale; public final class PostgresStorageRepository implements AutoCloseable { @@ -465,7 +466,7 @@ public final class PostgresStorageRepository String sql = "INSERT INTO solana_user_pda_current (" + - "pda_address, login, record_number, slot, last_tx_signature, " + + "pda_address, login, normalized_login, record_number, slot, last_tx_signature, " + "recovery_key, root_key, client_key, blockchain_name, " + "blockchain_key, paid_limit_bytes, used_bytes, " + "last_block_number, last_block_hash, last_block_signature, " + @@ -475,9 +476,10 @@ public final class PostgresStorageRepository "trusted_count, created_at_ms, updated_at_ms, " + "prev_record_hash, record_signature, raw_data_base64, " + "first_seen_at_ms, last_synced_at_ms" + - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (pda_address) DO UPDATE SET " + "login = EXCLUDED.login, " + + "normalized_login = EXCLUDED.normalized_login, " + "record_number = EXCLUDED.record_number, " + "slot = EXCLUDED.slot, " + "last_tx_signature = EXCLUDED.last_tx_signature, " + @@ -679,36 +681,41 @@ public final class PostgresStorageRepository statement.setString(1, snapshot.pdaAddress()); statement.setString(2, snapshot.login()); - statement.setInt(3, snapshot.recordNumber()); - statement.setLong(4, snapshot.slot()); - statement.setString(5, snapshot.lastTxSignature()); - statement.setString(6, snapshot.recoveryKey()); - statement.setString(7, snapshot.rootKey()); - statement.setString(8, snapshot.clientKey()); - statement.setString(9, snapshot.blockchainName()); - statement.setString(10, snapshot.blockchainKey()); - statement.setLong(11, snapshot.paidLimitBytes()); - statement.setLong(12, snapshot.usedBytes()); - statement.setInt(13, snapshot.lastBlockNumber()); - statement.setString(14, snapshot.lastBlockHash()); - statement.setString(15, snapshot.lastBlockSignature()); - statement.setString(16, snapshot.arweaveTxId()); - statement.setBoolean(17, snapshot.isServer()); - statement.setInt(18, snapshot.addressFormatType()); - statement.setInt(19, snapshot.addressFormatVersion()); - statement.setString(20, snapshot.serverAddress()); - statement.setString(21, writeJson(snapshot.syncServers())); - statement.setString(22, writeJson(snapshot.accessServers())); - statement.setInt(23, snapshot.sessionsMode()); - statement.setString(24, writeJson(snapshot.sessions())); - statement.setInt(25, snapshot.trustedCount()); - statement.setLong(26, snapshot.createdAtMs()); - statement.setLong(27, snapshot.updatedAtMs()); - statement.setString(28, snapshot.prevRecordHash()); - statement.setString(29, snapshot.recordSignature()); - statement.setString(30, snapshot.rawDataBase64()); - statement.setLong(31, nowMs); + statement.setString(3, normalizeLogin(snapshot.login())); + statement.setInt(4, snapshot.recordNumber()); + statement.setLong(5, snapshot.slot()); + statement.setString(6, snapshot.lastTxSignature()); + statement.setString(7, snapshot.recoveryKey()); + statement.setString(8, snapshot.rootKey()); + statement.setString(9, snapshot.clientKey()); + statement.setString(10, snapshot.blockchainName()); + statement.setString(11, snapshot.blockchainKey()); + statement.setLong(12, snapshot.paidLimitBytes()); + statement.setLong(13, snapshot.usedBytes()); + statement.setInt(14, snapshot.lastBlockNumber()); + statement.setString(15, snapshot.lastBlockHash()); + statement.setString(16, snapshot.lastBlockSignature()); + statement.setString(17, snapshot.arweaveTxId()); + statement.setBoolean(18, snapshot.isServer()); + statement.setInt(19, snapshot.addressFormatType()); + statement.setInt(20, snapshot.addressFormatVersion()); + statement.setString(21, snapshot.serverAddress()); + statement.setString(22, writeJson(snapshot.syncServers())); + statement.setString(23, writeJson(snapshot.accessServers())); + statement.setInt(24, snapshot.sessionsMode()); + statement.setString(25, writeJson(snapshot.sessions())); + statement.setInt(26, snapshot.trustedCount()); + statement.setLong(27, snapshot.createdAtMs()); + statement.setLong(28, snapshot.updatedAtMs()); + statement.setString(29, snapshot.prevRecordHash()); + statement.setString(30, snapshot.recordSignature()); + statement.setString(31, snapshot.rawDataBase64()); statement.setLong(32, nowMs); + statement.setLong(33, nowMs); + } + + private String normalizeLogin(String login) { + return login == null ? "" : login.trim().toLowerCase(Locale.ROOT); } private ShineUsersCodec.UserPdaSnapshot mapSnapshot( @@ -911,6 +918,7 @@ public final class PostgresStorageRepository "CREATE TABLE IF NOT EXISTS solana_user_pda_current (" + "pda_address TEXT PRIMARY KEY, " + "login TEXT NOT NULL UNIQUE, " + + "normalized_login TEXT NOT NULL, " + "record_number INTEGER NOT NULL, " + "slot BIGINT NOT NULL, " + "last_tx_signature TEXT NOT NULL, " + @@ -943,11 +951,29 @@ public final class PostgresStorageRepository "last_synced_at_ms BIGINT NOT NULL" + ")" ); + statement.executeUpdate( + "ALTER TABLE solana_user_pda_current " + + "ADD COLUMN IF NOT EXISTS normalized_login TEXT" + ); + statement.executeUpdate( + "UPDATE solana_user_pda_current " + + "SET normalized_login = LOWER(BTRIM(login)) " + + "WHERE normalized_login IS NULL " + + " OR normalized_login <> LOWER(BTRIM(login))" + ); statement.executeUpdate( "CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " + "ON solana_user_pda_current(slot)" ); + statement.executeUpdate( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login " + + "ON solana_user_pda_current(normalized_login)" + ); + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login " + + "ON solana_user_pda_current(normalized_login)" + ); statement.executeUpdate( "CREATE TABLE IF NOT EXISTS solana_user_pda_history (" + diff --git a/VERSION.properties b/VERSION.properties index d31849b1..35dcd70b 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.39 -server.version=1.4.12 +client.version=1.5.40 +server.version=1.4.13 diff --git a/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md index b1f121ac..66e80e0d 100644 --- a/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md +++ b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md @@ -13,6 +13,9 @@ - `user_access_servers_current` - `solana_user_pda_history` - источник истины по пользовательским PDA: `solana_user_pda_current`. +- в `solana_user_pda_current` хранятся оба варианта логина: + - `login` — display-логин из PDA; + - `normalized_login` — канонический lower-case для runtime lookup и части FK; - `user_access_servers_current` — это вторичная локальная проекция для быстрого роутинга DM по access servers; она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей. @@ -56,7 +59,7 @@ psql \ Скрипт: - создаёт таблицу версии схемы `db_schema_version`; -- ставит `schema_version = 2`; +- ставит актуальный `schema_version`; - создаёт таблицы sync-модуля Solana users; - создаёт server runtime tables; - создаёт триггеры и функции автоматической актуализации `user_access_servers_current`; diff --git a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md index 132922e6..6d432b78 100644 --- a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md +++ b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md @@ -323,6 +323,7 @@ Append-only журнал всех просмотренных транзакци - `pda_address TEXT PRIMARY KEY` - `login TEXT NOT NULL` +- `normalized_login TEXT NOT NULL` - `record_number INTEGER NOT NULL` - `slot INTEGER NOT NULL` - `last_tx_signature TEXT NOT NULL` @@ -349,9 +350,16 @@ Append-only журнал всех просмотренных транзакци Индексы: - уникальный индекс на `login` +- уникальный индекс на `normalized_login` - индекс на `slot` - индекс на `last_tx_signature` +Правило использования: + +- `login` хранит display-логин ровно в том регистре, как он записан в PDA; +- `normalized_login` хранит канонический lower-case логин; +- server runtime может использовать `normalized_login` для lookup и FK там, где внутренние записи живут в canonical lower-case. + ### 4. `solana_user_pda_history` Append-only история всех версий пользовательских PDA. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index bb1b2b17..4a4b03a0 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -1,13 +1,8 @@ import { navigate, getRoute, - parseRouteFromPath, PRE_AUTH_PAGES, - getSwipeNavigationTarget, syncTrackedRouteHistory, - rememberToolbarRoute, - resetRememberedToolbarRoutes, - resolveToolbarActive, } from './router.js'; import { renderToolbar } from './components/toolbar.js'; import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js'; @@ -173,14 +168,6 @@ const SIGNED_DM_DECRYPT_CONTEXT_POLL_MS = 50; const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000; const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim(); const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/; -const KEEP_ALIVE_ROOTS = new Set(['messages-list', 'channels-list']); -const HORIZONTAL_SWIPE_MIN_DISTANCE_PX = 72; -const HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX = 56; -const HORIZONTAL_SWIPE_DOMINANCE_RATIO = 1.35; -const HORIZONTAL_SWIPE_LOCK_DISTANCE_PX = 14; -const HORIZONTAL_SWIPE_COMMIT_RATIO = 0.32; -const HORIZONTAL_SWIPE_PREVIEW_EDGE_PX = 18; -const HORIZONTAL_SWIPE_MAX_DURATION_MS = 260; let currentCleanup = null; let pingIntervalId = null; @@ -202,9 +189,6 @@ let hiddenDmAudioUnlocked = false; let initialConnectionCompleted = false; let orientationLockInFlight = false; let currentChromeCleanup = null; -let currentMountState = null; -let activeSwipePreview = null; -const keepAliveEntries = new Map(); const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; const GUEST_ALLOWED_PAGES = new Set([ 'start-view', @@ -319,403 +303,11 @@ function createChromeController(showAppChrome) { }; } -function destroyMountState(entry) { - if (!entry) return; - if (entry.destroyed) return; - entry.destroyed = true; - try { - if (typeof entry.cleanup === 'function') { - entry.cleanup(); - } - } finally { - entry.chrome?.dispose?.(); - } -} - function clearKeepAliveEntries() { - teardownSwipePreview({ cancelOnly: true }); - keepAliveEntries.forEach((entry) => destroyMountState(entry)); - keepAliveEntries.clear(); - resetRememberedToolbarRoutes(); - currentMountState = null; currentCleanup = null; currentChromeCleanup = null; } -function detachMountedScreen(entry) { - if (!entry) return; - entry.chrome?.suspend?.(); - if (entry.screen?.parentNode === screenEl) { - screenEl.removeChild(entry.screen); - } else { - screenEl.innerHTML = ''; - } -} - -function mountExistingEntry(entry, { showAppChrome, pageId }) { - teardownSwipePreview({ cancelOnly: true }); - screenEl.innerHTML = ''; - screenEl.append(entry.screen); - entry.chrome?.resume?.(); - currentMountState = entry; - currentCleanup = typeof entry.cleanup === 'function' ? entry.cleanup : null; - currentChromeCleanup = () => entry.chrome?.dispose?.(); - screenEl.classList.toggle('no-app-chrome', !showAppChrome); - screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); -} - -function cloneScreenForSwipe(screen) { - const clone = screen?.cloneNode?.(true); - if (!(clone instanceof Node)) return null; - return clone; -} - -function sanitizeSwipeClone(node) { - if (!(node instanceof Element)) return; - node.removeAttribute('id'); - node.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id')); -} - -function cloneSlotChildForSwipe(slotEl) { - const child = slotEl?.firstElementChild; - if (!(child instanceof Node)) return null; - const clone = child.cloneNode(true); - if (clone instanceof Element) sanitizeSwipeClone(clone); - return clone; -} - -function createSwipeFrameSlot(className, contentNode = null) { - const slot = document.createElement('div'); - slot.className = className; - if (contentNode instanceof Node) { - slot.append(contentNode); - slot.hidden = false; - } else { - slot.hidden = true; - } - return slot; -} - -function buildSwipePane({ - topbarNode = null, - screenNode = null, - composerNode = null, - screenClassName = '', - screenScrollTop = 0, -}) { - const pane = document.createElement('div'); - pane.className = 'screen-swipe-pane'; - - const topbarSlot = createSwipeFrameSlot('topbar-slot screen-swipe-slot screen-swipe-slot--topbar', topbarNode); - const screenSlot = document.createElement('main'); - screenSlot.className = `${screenClassName || 'screen-content'} screen-swipe-slot screen-swipe-slot--content`; - if (screenNode instanceof Node) { - screenSlot.append(screenNode); - } - const composerSlot = createSwipeFrameSlot('composer-slot screen-swipe-slot screen-swipe-slot--composer', composerNode); - - pane.append(topbarSlot, screenSlot, composerSlot); - requestAnimationFrame(() => { - screenSlot.scrollTop = Math.max(0, Number(screenScrollTop || 0)); - }); - return pane; -} - -function createSwipePreviewTarget(targetPath) { - const route = parseRouteFromPath(`/${String(targetPath || '').replace(/^\/+/, '')}`); - const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); - const page = routes[pageId] || routes['start-view']; - const rootPageId = resolveToolbarActive(pageId); - const cachedEntry = keepAliveEntries.get(rootPageId); - if (cachedEntry && cachedEntry.routePath === `/${String(targetPath || '').replace(/^\/+/, '')}`) { - return { - screen: cloneScreenForSwipe(cachedEntry.screen), - cleanup: null, - }; - } - - let previewTopbarNode = null; - let previewComposerNode = null; - const chrome = { - setTopbar(node = null) { - previewTopbarNode = node instanceof Node ? node : null; - }, - setComposer(node = null) { - previewComposerNode = node instanceof Node ? node : null; - }, - clear() { - previewTopbarNode = null; - previewComposerNode = null; - }, - suspend() {}, - resume() {}, - dispose() {}, - }; - const screen = page.render({ route, navigate, chrome }); - if (!(screen instanceof Node)) { - chrome.dispose(); - throw new Error('Swipe preview render returned invalid node'); - } - const cleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; - return { - screen, - topbarNode: previewTopbarNode, - composerNode: previewComposerNode, - cleanup: () => { - try { - if (typeof cleanup === 'function') cleanup(); - } finally { - chrome.dispose(); - } - }, - }; -} - -function applySwipePreviewOffset(session, revealPx) { - if (!session) return; - const width = Math.max(1, session.width); - const clamped = Math.max(0, Math.min(width, revealPx)); - session.revealPx = clamped; - - const currentX = session.direction === 'left' ? -clamped : clamped; - const targetX = session.direction === 'left' - ? width - clamped + HORIZONTAL_SWIPE_PREVIEW_EDGE_PX - : -width + clamped - HORIZONTAL_SWIPE_PREVIEW_EDGE_PX; - const dividerX = session.direction === 'left' - ? width - clamped - : clamped; - - session.currentPane.style.transform = `translate3d(${currentX}px, 0, 0)`; - session.targetPane.style.transform = `translate3d(${targetX}px, 0, 0)`; - session.divider.style.transform = `translate3d(${dividerX}px, 0, 0)`; - - const overlayOpacity = Math.max(0.08, Math.min(0.24, (clamped / width) * 0.24)); - session.overlay.style.setProperty('--swipe-overlay-opacity', overlayOpacity.toFixed(3)); -} - -function teardownSwipePreview({ cancelOnly = false } = {}) { - const session = activeSwipePreview; - if (!session) return; - activeSwipePreview = null; - - appShellEl?.classList.remove('app-shell--swiping'); - topbarEl?.classList.remove('topbar-slot--swipe-hidden'); - screenEl.classList.remove('screen-content--swipe-hidden'); - composerEl?.classList.remove('composer-slot--swipe-hidden'); - session.overlay.remove(); - if (typeof session.targetCleanup === 'function') { - session.targetCleanup(); - } - if (!cancelOnly) { - session.onComplete?.(); - } -} - -function animateSwipePreviewTo(session, revealPx, { complete = false } = {}) { - const width = Math.max(1, session.width); - const currentReveal = Number(session.revealPx || 0); - const remaining = Math.abs(revealPx - currentReveal); - const duration = Math.max(140, Math.min(HORIZONTAL_SWIPE_MAX_DURATION_MS, Math.round((remaining / width) * HORIZONTAL_SWIPE_MAX_DURATION_MS))); - - [session.currentPane, session.targetPane, session.divider].forEach((node) => { - node.style.transition = `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`; - }); - session.overlay.style.transition = `opacity ${duration}ms ease`; - - requestAnimationFrame(() => { - applySwipePreviewOffset(session, revealPx); - if (!complete) { - session.overlay.style.opacity = '0'; - } - }); - - window.setTimeout(() => { - teardownSwipePreview({ cancelOnly: !complete }); - }, duration + 24); -} - -function beginSwipePreview(direction, targetPath) { - if (!currentMountState?.screen || activeSwipePreview) return null; - const currentTopbarClone = cloneSlotChildForSwipe(topbarEl); - const currentScreenClone = cloneScreenForSwipe(currentMountState.screen); - const currentComposerClone = cloneSlotChildForSwipe(composerEl); - if (!(currentScreenClone instanceof Node)) return null; - if (currentTopbarClone instanceof Element) sanitizeSwipeClone(currentTopbarClone); - if (currentScreenClone instanceof Element) sanitizeSwipeClone(currentScreenClone); - if (currentComposerClone instanceof Element) sanitizeSwipeClone(currentComposerClone); - - const targetPreview = createSwipePreviewTarget(targetPath); - if (!(targetPreview?.screen instanceof Node)) { - targetPreview?.cleanup?.(); - return null; - } - if (targetPreview.topbarNode instanceof Element) sanitizeSwipeClone(targetPreview.topbarNode); - if (targetPreview.screen instanceof Element) sanitizeSwipeClone(targetPreview.screen); - if (targetPreview.composerNode instanceof Element) sanitizeSwipeClone(targetPreview.composerNode); - - const overlay = document.createElement('div'); - overlay.className = 'screen-swipe-overlay'; - - const currentPane = buildSwipePane({ - topbarNode: currentTopbarClone, - screenNode: currentScreenClone, - composerNode: currentComposerClone, - screenClassName: screenEl.className, - screenScrollTop: screenEl.scrollTop, - }); - currentPane.classList.add('screen-swipe-pane--current'); - - const targetPane = buildSwipePane({ - topbarNode: targetPreview.topbarNode || null, - screenNode: targetPreview.screen, - composerNode: targetPreview.composerNode || null, - screenClassName: screenEl.className, - screenScrollTop: 0, - }); - targetPane.classList.add('screen-swipe-pane--target', `screen-swipe-pane--${direction}`); - - const divider = document.createElement('div'); - divider.className = 'screen-swipe-divider'; - - overlay.append(currentPane, targetPane, divider); - appShellEl.append(overlay); - appShellEl?.classList.add('app-shell--swiping'); - topbarEl?.classList.add('topbar-slot--swipe-hidden'); - screenEl.classList.add('screen-content--swipe-hidden'); - composerEl?.classList.add('composer-slot--swipe-hidden'); - - const session = { - direction, - targetPath, - width: screenEl.clientWidth || 1, - overlay, - currentPane, - targetPane, - divider, - targetCleanup: targetPreview.cleanup || null, - revealPx: 0, - onComplete: () => navigate(targetPath), - }; - activeSwipePreview = session; - applySwipePreviewOffset(session, 0); - return session; -} - -function installHorizontalTabSwipe() { - if (!screenEl) return; - - let touchStartX = 0; - let touchStartY = 0; - let touchActive = false; - let touchBlocked = false; - let swipeLocked = false; - let swipeDirection = ''; - let swipeTargetPath = ''; - let swipeSession = null; - - const reset = () => { - touchActive = false; - touchBlocked = false; - swipeLocked = false; - swipeDirection = ''; - swipeTargetPath = ''; - swipeSession = null; - touchStartX = 0; - touchStartY = 0; - }; - - screenEl.addEventListener('touchstart', (event) => { - if (event.touches.length !== 1) { - reset(); - return; - } - const target = event.target instanceof Element ? event.target : null; - touchBlocked = Boolean(target?.closest('input, textarea, select, button, a, [contenteditable="true"]')); - touchActive = !touchBlocked; - touchStartX = Number(event.touches[0]?.clientX || 0); - touchStartY = Number(event.touches[0]?.clientY || 0); - }, { passive: true }); - - screenEl.addEventListener('touchmove', (event) => { - if (!touchActive || touchBlocked) return; - const touch = event.touches?.[0]; - const deltaX = Number(touch?.clientX || 0) - touchStartX; - const deltaY = Number(touch?.clientY || 0) - touchStartY; - const absX = Math.abs(deltaX); - const absY = Math.abs(deltaY); - - if (!swipeLocked) { - if (absX < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX && absY < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX) return; - if (absX <= absY * 1.05) { - touchBlocked = true; - return; - } - const currentPageId = getRoute().pageId || ''; - swipeDirection = deltaX < 0 ? 'left' : 'right'; - swipeTargetPath = getSwipeNavigationTarget(currentPageId, swipeDirection); - if (!swipeTargetPath) { - touchBlocked = true; - return; - } - swipeSession = beginSwipePreview(swipeDirection, swipeTargetPath); - if (!swipeSession) { - touchBlocked = true; - return; - } - swipeLocked = true; - } - - if (!swipeLocked || !swipeSession) return; - event.preventDefault(); - - const revealPx = swipeDirection === 'left' - ? Math.max(0, -deltaX) - : Math.max(0, deltaX); - applySwipePreviewOffset(swipeSession, revealPx); - }, { passive: false }); - - screenEl.addEventListener('touchcancel', reset, { passive: true }); - - screenEl.addEventListener('touchend', (event) => { - if (!touchActive || touchBlocked) { - if (swipeSession) { - animateSwipePreviewTo(swipeSession, 0, { complete: false }); - } - reset(); - return; - } - - const touch = event.changedTouches?.[0]; - const endX = Number(touch?.clientX || 0); - const endY = Number(touch?.clientY || 0); - const deltaX = endX - touchStartX; - const deltaY = endY - touchStartY; - const absX = Math.abs(deltaX); - const absY = Math.abs(deltaY); - const session = swipeSession; - const wasLocked = swipeLocked; - reset(); - - if (wasLocked && session) { - event.preventDefault(); - const revealRatio = Number(session.revealPx || 0) / Math.max(1, session.width); - const shouldCommit = revealRatio >= HORIZONTAL_SWIPE_COMMIT_RATIO; - animateSwipePreviewTo(session, shouldCommit ? session.width : 0, { complete: shouldCommit }); - return; - } - - if (absX < HORIZONTAL_SWIPE_MIN_DISTANCE_PX) return; - if (absY > HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX) return; - if (absX <= absY * HORIZONTAL_SWIPE_DOMINANCE_RATIO) return; - - const currentPageId = getRoute().pageId || ''; - const direction = deltaX < 0 ? 'left' : 'right'; - const target = getSwipeNavigationTarget(currentPageId, direction); - if (!target) return; - navigate(target); - }, { passive: true }); -} - async function unlockHiddenDmAudio() { try { const Ctx = window.AudioContext || window.webkitAudioContext; @@ -1396,7 +988,6 @@ function renderPageFailureFallback(pageId, error) { }); screenEl.innerHTML = ''; - teardownSwipePreview({ cancelOnly: true }); const wrap = document.createElement('section'); wrap.className = 'stack'; @@ -1433,7 +1024,6 @@ function renderPageFailureFallback(pageId, error) { } function renderApp() { - teardownSwipePreview({ cancelOnly: true }); syncTrackedRouteHistory(window.location.pathname || '/'); const route = getRoute(); const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); @@ -1450,56 +1040,13 @@ function renderApp() { const page = routes[pageId] || routes['start-view']; const showAppChrome = page.pageMeta?.showAppChrome !== false; - const rootPageId = resolveToolbarActive(pageId); - const keepAliveEligible = showAppChrome && KEEP_ALIVE_ROOTS.has(rootPageId); - const currentRoutePath = String(window.location.pathname || '/'); - - rememberToolbarRoute(pageId); - - if (currentMountState) { - const shouldPreserveCurrent = currentMountState.keepAlive && currentMountState.rootPageId !== rootPageId; - if (shouldPreserveCurrent) { - currentMountState.routePath = currentMountState.routePath || currentRoutePath; - keepAliveEntries.set(currentMountState.rootPageId, currentMountState); - detachMountedScreen(currentMountState); - currentMountState = null; - currentCleanup = null; - currentChromeCleanup = null; - } else { - destroyMountState(currentMountState); - if (currentMountState.keepAlive) { - keepAliveEntries.delete(currentMountState.rootPageId); - } - currentMountState = null; - currentCleanup = null; - currentChromeCleanup = null; - } - } else { - if (typeof currentCleanup === 'function') { - currentCleanup(); - currentCleanup = null; - } - if (typeof currentChromeCleanup === 'function') { - currentChromeCleanup(); - currentChromeCleanup = null; - } + if (typeof currentCleanup === 'function') { + currentCleanup(); + currentCleanup = null; } - - const cachedEntry = keepAliveEligible ? keepAliveEntries.get(rootPageId) : null; - if (cachedEntry && cachedEntry.routePath === currentRoutePath) { - mountExistingEntry(cachedEntry, { showAppChrome, pageId }); - toolbarEl.innerHTML = ''; - if (showAppChrome) { - toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); - } - toolbarHeightObserver?.sync?.(); - refreshConnectionUi(); - return; - } - - if (cachedEntry) { - destroyMountState(cachedEntry); - keepAliveEntries.delete(rootPageId); + if (typeof currentChromeCleanup === 'function') { + currentChromeCleanup(); + currentChromeCleanup = null; } try { @@ -1513,19 +1060,6 @@ function renderApp() { screenEl.append(screen); currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; - currentMountState = { - pageId, - rootPageId, - keepAlive: keepAliveEligible, - routePath: currentRoutePath, - screen, - cleanup: currentCleanup, - chrome, - destroyed: false, - }; - if (keepAliveEligible) { - keepAliveEntries.set(rootPageId, currentMountState); - } screenEl.classList.toggle('no-app-chrome', !showAppChrome); screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); @@ -2008,7 +1542,6 @@ async function init() { })(); window.addEventListener('popstate', renderApp); - installHorizontalTabSwipe(); document.addEventListener('pointerdown', () => { void unlockHiddenDmAudio(); }, { passive: true }); diff --git a/shine-UI/js/components/arweave-attachment-manager.js b/shine-UI/js/components/arweave-attachment-manager.js index 72f5529d..1885c9ea 100644 --- a/shine-UI/js/components/arweave-attachment-manager.js +++ b/shine-UI/js/components/arweave-attachment-manager.js @@ -284,10 +284,22 @@ export function openArweaveAttachmentManager({ onSelect, selectedTxIds = [], historyOnly = false, + persistToHistory = true, + allowHistorySelection = true, + allowExistingTxInput = true, mode = 'attachment', historyPurpose = '', uploadTransport = 'turbo', turboKeySource = 'client', + dialogTitle = '', + uploadButtonLabel = '', + initialFile = null, + initialSha256 = '', + initialName = '', + fixedFile = false, + autoOpenFileDialog = true, + shineType = '', + extraUploadTags = [], } = {}) { const cleanLogin = String(login || '').trim(); const cleanStoragePwd = String(storagePwd || '').trim(); @@ -312,7 +324,7 @@ export function openArweaveAttachmentManager({ let selectedPreviewPriceInfo = null; let priceInfo = null; let balanceInfo = null; - let autoOpenedFileDialog = false; + let autoOpenedFileDialogOnce = false; const isAvatarMode = String(mode || '') === 'avatar'; if (isAvatarMode && !String(uploadTransport || '').trim()) { selectedUploadTransport = 'turbo'; @@ -320,6 +332,16 @@ export function openArweaveAttachmentManager({ const historyPurposeMode = String(historyPurpose || '').trim(); const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment'; const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean)); + const effectiveDialogTitle = String(dialogTitle || '').trim(); + const effectiveUploadButtonLabel = String(uploadButtonLabel || '').trim(); + const forcedShineType = String(shineType || '').trim(); + const normalizedExtraUploadTags = Array.isArray(extraUploadTags) + ? extraUploadTags.filter((item) => item?.name && item?.value) + : []; + if (initialFile instanceof File) { + selectedFile = initialFile; + if (initialSha256) selectedSha256 = String(initialSha256 || '').trim().toLowerCase(); + } function isTurboUpload() { return selectedUploadTransport === 'turbo'; @@ -337,10 +359,15 @@ export function openArweaveAttachmentManager({ } function finish(resolve, attachment, { pendingPlacement = undefined } = {}) { - const item = addArweaveAttachmentToHistory(cleanLogin, attachment, { - pendingPlacement, - markPlaced: false, - }); + const item = persistToHistory + ? addArweaveAttachmentToHistory(cleanLogin, attachment, { + pendingPlacement, + markPlaced: false, + }) + : { + ...attachment, + ...normalizeAttachment(attachment), + }; if (!pendingPlacement && typeof onSelect === 'function') onSelect(item); close(resolve, item); } @@ -588,16 +615,21 @@ export function openArweaveAttachmentManager({ const showUpload = async () => { const turboMode = isTurboUpload(); + const titleText = effectiveDialogTitle + || (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))); + const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить'); + const canShowHistory = allowHistorySelection; + const canShowExisting = allowExistingTxInput; root.innerHTML = `