From 8f32e82d14fb4fda914b213edc923b8308cd6e27f9a50b10d26cc84ed48d88e0 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 12 Aug 2026 13:14:23 +0400 Subject: [PATCH 1/3] =?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/3] =?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/3] =?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;