SHA256
Merge ветки расширения функционала каналов в main
This commit is contained in:
+21
-5
@@ -6,6 +6,8 @@ import java.util.Map;
|
|||||||
public final class ChannelMetaTextParser {
|
public final class ChannelMetaTextParser {
|
||||||
public static final int MAX_TITLE_CHARS = 50;
|
public static final int MAX_TITLE_CHARS = 50;
|
||||||
public static final int MAX_DESCRIPTION_CHARS = 250;
|
public static final int MAX_DESCRIPTION_CHARS = 250;
|
||||||
|
private static final String LEGACY_PREFIX = "<SHiNE:";
|
||||||
|
private static final String SHORT_PREFIX = "<S:";
|
||||||
|
|
||||||
private ChannelMetaTextParser() {}
|
private ChannelMetaTextParser() {}
|
||||||
|
|
||||||
@@ -19,15 +21,16 @@ public final class ChannelMetaTextParser {
|
|||||||
boolean seenAvatar = false;
|
boolean seenAvatar = false;
|
||||||
|
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
while (text.startsWith("<SHiNE:", offset)) {
|
while (startsWithTagPrefix(text, offset)) {
|
||||||
int end = text.indexOf('>', offset);
|
int end = text.indexOf('>', offset);
|
||||||
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
|
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
|
||||||
String body = text.substring(offset + "<SHiNE:".length(), end);
|
int prefixLength = tagPrefixLength(text, offset);
|
||||||
|
String body = text.substring(offset + prefixLength, end);
|
||||||
if (body.startsWith("title;")) {
|
if (body.startsWith("title;")) {
|
||||||
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
|
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
|
||||||
seenTitle = true;
|
seenTitle = true;
|
||||||
title = parseTitleTag(body);
|
title = parseTitleTag(body);
|
||||||
} else if (body.startsWith("avatar;")) {
|
} else if (body.startsWith("avatar;") || body.startsWith("ava;")) {
|
||||||
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
|
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
|
||||||
seenAvatar = true;
|
seenAvatar = true;
|
||||||
Avatar avatar = parseAvatarTag(body);
|
Avatar avatar = parseAvatarTag(body);
|
||||||
@@ -61,11 +64,14 @@ public final class ChannelMetaTextParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Avatar parseAvatarTag(String body) {
|
private static Avatar parseAvatarTag(String body) {
|
||||||
Map<String, String> fields = parseFields(body.substring("avatar;".length()));
|
String rawFields = body.startsWith("ava;")
|
||||||
|
? body.substring("ava;".length())
|
||||||
|
: body.substring("avatar;".length());
|
||||||
|
Map<String, String> fields = parseFields(rawFields);
|
||||||
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
|
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
|
||||||
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
|
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
|
||||||
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
|
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 (!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");
|
if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256");
|
||||||
long size;
|
long size;
|
||||||
@@ -78,6 +84,16 @@ public final class ChannelMetaTextParser {
|
|||||||
return new Avatar(ar, sha256, size);
|
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<String, String> parseFields(String raw) {
|
private static Map<String, String> parseFields(String raw) {
|
||||||
Map<String, String> out = new HashMap<>();
|
Map<String, String> out = new HashMap<>();
|
||||||
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
|
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.6.0
|
client.version=1.7.0
|
||||||
server.version=1.5.0
|
server.version=1.5.0
|
||||||
|
|||||||
@@ -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:
|
Пример текстового содержимого body:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||||
Текст сообщения
|
Текст сообщения
|
||||||
```
|
```
|
||||||
|
|
||||||
Пример вложения с отдельным preview-файлом:
|
Пример вложения с отдельным preview-файлом:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
|
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
|
||||||
@@ -183,8 +183,8 @@
|
|||||||
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
|
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:title;v=1;Человекочитаемое имя канала>
|
<S:title;v=1;Человекочитаемое имя канала>
|
||||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||||
Описание канала
|
Описание канала
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -197,12 +197,12 @@
|
|||||||
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:title;v=1;Новое имя канала>
|
<S:title;v=1;Новое имя канала>
|
||||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
<S:ava;v=1;sz=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||||
Новое описание канала
|
Новое описание канала
|
||||||
```
|
```
|
||||||
|
|
||||||
Каждый `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. Хватает ли функций сейчас
|
## 7. Хватает ли функций сейчас
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ Payload включает:
|
|||||||
Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`:
|
Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:title;v=1;Название канала>
|
<S:title;v=1;Название канала>
|
||||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
<S:ava;v=1;sz=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||||
Описание канала.
|
Описание канала.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
|
|
||||||
7. `subType=90` — `TEXT_CHANNEL_META`
|
7. `subType=90` — `TEXT_CHANNEL_META`
|
||||||
- скрытый технический снимок профиля канала;
|
- скрытый технический снимок профиля канала;
|
||||||
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
|
- содержит line-поля + текст с тегами `S:title`/`S:ava` и описанием;
|
||||||
- не отображается как обычное сообщение ленты;
|
- не отображается как обычное сообщение ленты;
|
||||||
- применяется сервером к текущему состоянию канала.
|
- применяется сервером к текущему состоянию канала.
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
||||||
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
||||||
|
|
||||||
## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`)
|
## Вложения в текстовых сообщениях (`S:att v=1`)
|
||||||
|
|
||||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
|
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
|
||||||
|
|
||||||
@@ -84,31 +84,31 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
Один блок вложения:
|
Один блок вложения:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
<S:att;v=1;nm=report.pdf;sz=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
||||||
```
|
```
|
||||||
|
|
||||||
Для файлов с отдельным превью клиент может использовать расширенный вариант:
|
Для файлов с отдельным превью клиент может использовать расширенный вариант:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeee...>
|
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeee...>
|
||||||
```
|
```
|
||||||
|
|
||||||
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
|
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
|
||||||
|
|
||||||
Обязательные поля:
|
Обязательные поля:
|
||||||
|
|
||||||
- `v=1` или `v=2`
|
- `v=1`
|
||||||
- `name` - имя файла, закодированное через `encodeURIComponent`
|
- `nm` - имя файла, закодированное через `encodeURIComponent`
|
||||||
- `size` - размер файла в байтах
|
- `sz` - размер файла в байтах
|
||||||
- `sha256` - SHA-256 исходного файла в hex
|
- `sha256` - SHA-256 исходного файла в hex
|
||||||
- `ar` - короткий Arweave Transaction ID, без полного URL
|
- `ar` - короткий Arweave Transaction ID, без полного URL
|
||||||
- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256`
|
- при наличии превью дополнительно могут присутствовать `preAr` и `preSha256`
|
||||||
|
|
||||||
Правила клиента:
|
Правила клиента:
|
||||||
|
|
||||||
- если сообщение начинается с одного или нескольких валидных `<SHiNE:attach;...>` блоков, новый клиент скрывает эти блоки и показывает карточки вложений;
|
- если сообщение начинается с одного или нескольких валидных attach-блоков (`<SHiNE:attach;...>`, `<S:attach;...>` или `<S:att;...>`), новый клиент скрывает эти блоки и показывает карточки вложений;
|
||||||
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
|
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
|
||||||
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
|
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
|
||||||
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
|
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
|
||||||
|
|
||||||
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`.
|
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `preAr/preSha256`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`)
|
# Вложения в TEXT-сообщениях (`S:att v=1`)
|
||||||
|
|
||||||
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
|
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
|
||||||
|
|
||||||
@@ -15,23 +15,23 @@
|
|||||||
|
|
||||||
## Общий вид
|
## Общий вид
|
||||||
|
|
||||||
Один attach-блок:
|
Канонический attach-блок:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
<S:att;v=1;nm=report.pdf;sz=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
||||||
```
|
```
|
||||||
|
|
||||||
Блок с превью:
|
Блок с превью:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
<S:att;v=1;nm=video.mp4;sz=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;preAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;preSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||||
```
|
```
|
||||||
|
|
||||||
Несколько вложений идут подряд в самом начале текста:
|
Несколько вложений идут подряд в самом начале текста:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
<S:att;v=1;nm=photo.jpg;sz=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
<S:att;v=1;nm=report.pdf;sz=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||||
Текст сообщения
|
Текст сообщения
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -41,18 +41,18 @@
|
|||||||
|
|
||||||
## Поля
|
## Поля
|
||||||
|
|
||||||
Обязательные поля:
|
Обязательные поля канонического формата:
|
||||||
|
|
||||||
- `v=1` - версия формата attach-блока;
|
- `v=1` - версия формата attach-блока;
|
||||||
- `name` - имя файла, закодированное через `encodeURIComponent`;
|
- `nm` - имя файла, закодированное через `encodeURIComponent`;
|
||||||
- `size` - размер файла в байтах;
|
- `sz` - размер файла в байтах;
|
||||||
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
|
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
|
||||||
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
|
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
|
||||||
|
|
||||||
Дополнительные поля для `v=2`:
|
Дополнительные поля для превью:
|
||||||
|
|
||||||
- `previewAr` - короткий Arweave Transaction ID файла-превью;
|
- `preAr` - короткий Arweave Transaction ID файла-превью;
|
||||||
- `previewSha256` - SHA-256 файла-превью в hex.
|
- `preSha256` - SHA-256 файла-превью в hex.
|
||||||
|
|
||||||
## Хранение файла
|
## Хранение файла
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
- SHA-256;
|
- SHA-256;
|
||||||
- Arweave `txId`.
|
- Arweave `txId`.
|
||||||
|
|
||||||
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`.
|
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `preAr/preSha256`.
|
||||||
|
|
||||||
## Создание вложения в UI
|
## Создание вложения в UI
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ UI поддерживает три сценария:
|
|||||||
- основной видеофайл;
|
- основной видеофайл;
|
||||||
- отдельное изображение-превью.
|
- отдельное изображение-превью.
|
||||||
|
|
||||||
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`.
|
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `preAr/preSha256`. В UI такой элемент помечается как файл `С превью`.
|
||||||
|
|
||||||
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
|
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ UI поддерживает три сценария:
|
|||||||
- изображение показывает как ограниченное по размеру превью;
|
- изображение показывает как ограниченное по размеру превью;
|
||||||
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
||||||
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
||||||
6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
|
6. если у видео есть `preAr/preSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
|
||||||
7. при перелистывании останавливает воспроизводящееся видео;
|
7. при перелистывании останавливает воспроизводящееся видео;
|
||||||
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
||||||
|
|
||||||
@@ -118,7 +118,9 @@ UI поддерживает три сценария:
|
|||||||
|
|
||||||
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
||||||
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
||||||
- старые блоки `v=1` без превью остаются валидными и читаются без изменений;
|
- старые блоки `<SHiNE:attach ...>`, промежуточные `<S:attach ...>` и новые `<S:att ...>` читаются одинаково;
|
||||||
- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны;
|
- старые поля `name/size/previewAr/previewSha256` и новые `nm/sz/preAr/preSha256` читаются одинаково;
|
||||||
|
- старые блоки `v=2` продолжают читаться как legacy-форма вложения с превью;
|
||||||
|
- `preAr/preSha256` используются только если присутствуют оба поля и оба валидны;
|
||||||
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
|
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
|
||||||
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
||||||
|
|||||||
@@ -18,15 +18,15 @@
|
|||||||
В начале текста могут идти технические теги, после них обычный текст описания:
|
В начале текста могут идти технические теги, после них обычный текст описания:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:title;v=1;Название канала>
|
<S:title;v=1;Название канала>
|
||||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
<S:ava;v=1;sz=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||||
Описание канала.
|
Описание канала.
|
||||||
```
|
```
|
||||||
|
|
||||||
Поддерживаемые теги:
|
Поддерживаемые теги:
|
||||||
|
|
||||||
- `<SHiNE:title;v=1;...>` — человекочитаемое имя канала.
|
- `<S:title;v=1;...>` — человекочитаемое имя канала.
|
||||||
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>` — аватар канала в Arweave.
|
- `<S:ava;v=1;sz=...;sha256=...;ar=...>` — аватар канала в Arweave.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
- Длина описания — максимум 250 Unicode code points.
|
- Длина описания — максимум 250 Unicode code points.
|
||||||
- `avatar.ar` — Arweave transaction id из 43 символов.
|
- `avatar.ar` — Arweave transaction id из 43 символов.
|
||||||
- `avatar.sha256` — 64 hex-символа.
|
- `avatar.sha256` — 64 hex-символа.
|
||||||
- `avatar.size` — положительный размер файла в байтах.
|
- `avatar.sz` — положительный размер файла в байтах.
|
||||||
|
|
||||||
Если meta-блок невалиден, сервер не применяет его целиком.
|
Если meta-блок невалиден, сервер не применяет его целиком.
|
||||||
|
|
||||||
@@ -59,3 +59,9 @@
|
|||||||
## Замена старых команд
|
## Замена старых команд
|
||||||
|
|
||||||
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
|
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
- Новый UI пишет сокращённые теги `<S:title ...>` и `<S:ava ...>`.
|
||||||
|
- Серверное чтение поддерживает и старые теги `<SHiNE:title ...>` / `<SHiNE:avatar ...>`.
|
||||||
|
- Для размера аватара чтение поддерживает и старое поле `size`, и новое поле `sz`.
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
|
## 2026-08-12 13:00:00 +0400
|
||||||
|
- Базовый коммит-ориентир: `working tree`.
|
||||||
|
- Канонический текстовый формат служебных тегов сокращён:
|
||||||
|
- `TEXT_CHANNEL_META` теперь пишет `<S:title>` и `<S:ava>`;
|
||||||
|
- вложения теперь пишутся как `<S:att;v=1;nm=...;sz=...;sha256=...;ar=...>`;
|
||||||
|
- превью во вложениях задаётся полями `preAr/preSha256` без отдельной новой версии `v=2`;
|
||||||
|
- DM-вставки теперь пишутся с префиксом `<S:`.
|
||||||
|
- Зафиксирована обратная совместимость чтения:
|
||||||
|
- старые теги `<SHiNE:...>` продолжают поддерживаться;
|
||||||
|
- старые поля `name/size/previewAr/previewSha256` продолжают поддерживаться;
|
||||||
|
- legacy-вложения `v=2` продолжают читаться как вложения с превью.
|
||||||
|
|
||||||
## 2026-08-09 23:30:06 +0400
|
## 2026-08-09 23:30:06 +0400
|
||||||
- Базовый коммит-ориентир: `ee185cf`.
|
- Базовый коммит-ориентир: `ee185cf`.
|
||||||
- Нумерация `STATUS_ACTION` уточнена под дневник действий:
|
- Нумерация `STATUS_ACTION` уточнена под дневник действий:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md)
|
8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md)
|
||||||
Статусные действия пользователя (`msg_type=5`).
|
Статусные действия пользователя (`msg_type=5`).
|
||||||
9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md)
|
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)
|
10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
|
||||||
Скрытый `TEXT_CHANNEL_META` для профиля канала.
|
Скрытый `TEXT_CHANNEL_META` для профиля канала.
|
||||||
11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
|
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
|
||||||
- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
|
- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
|
||||||
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<SHiNE:...>` вставок внутри plaintext DM после расшифровки
|
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
|
||||||
|
|
||||||
Исторический устаревший документ сохранён отдельно:
|
Исторический устаревший документ сохранён отдельно:
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
|
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
|
||||||
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
|
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
|
||||||
|
|
||||||
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<SHiNE:...>` в начале текста.
|
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<S:...>` в начале текста. Legacy-вставки `<SHiNE:...>` также продолжают поддерживаться при чтении.
|
||||||
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
|
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
|
||||||
|
|
||||||
### 2.4. Источник истины по пользователю
|
### 2.4. Источник истины по пользователю
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
Если в начале plaintext стоит один или несколько специальных блоков формата:
|
Если в начале plaintext стоит один или несколько специальных блоков формата:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:...>
|
<S:...>
|
||||||
```
|
```
|
||||||
|
|
||||||
то клиент трактует их как технические вставки.
|
то клиент трактует их как технические вставки.
|
||||||
@@ -26,14 +26,14 @@
|
|||||||
Техническими считаются только блоки, которые:
|
Техническими считаются только блоки, которые:
|
||||||
|
|
||||||
- стоят строго в начале plaintext;
|
- стоят строго в начале plaintext;
|
||||||
- начинаются с точного префикса `<SHiNE:`;
|
- начинаются с префикса `<S:` или legacy-префикса `<SHiNE:`;
|
||||||
- заканчиваются первым символом `>`.
|
- заканчиваются первым символом `>`.
|
||||||
|
|
||||||
Если текст не начинается с `<SHiNE:`, никакие технические вставки не ищутся.
|
Если текст не начинается с `<S:` или `<SHiNE:`, никакие технические вставки не ищутся.
|
||||||
|
|
||||||
## 2. Правило скрытия
|
## 2. Правило скрытия
|
||||||
|
|
||||||
Все корректно распознанные блоки `<SHiNE:...>` в начале plaintext:
|
Все корректно распознанные блоки `<S:...>` или `<SHiNE:...>` в начале plaintext:
|
||||||
|
|
||||||
- не показываются пользователю как сырой текст;
|
- не показываются пользователю как сырой текст;
|
||||||
- используются клиентом для UI-логики;
|
- используются клиентом для UI-логики;
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
Перед отправкой обычного текстового сообщения клиент обязан проверить:
|
Перед отправкой обычного текстового сообщения клиент обязан проверить:
|
||||||
|
|
||||||
- если пользовательский текст начинается с `<SHiNE:`
|
- если пользовательский текст начинается с `<S:` или `<SHiNE:`
|
||||||
|
|
||||||
то клиент автоматически превращает начало в:
|
то клиент автоматически превращает начало в:
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
Общий вид:
|
Общий вид:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:kind;key=value;key=value;...>
|
<S:kind;key=value;key=value;...>
|
||||||
```
|
```
|
||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
@@ -69,14 +69,15 @@
|
|||||||
- параметры отделяются `;`;
|
- параметры отделяются `;`;
|
||||||
- ключ и значение отделяются `=`;
|
- ключ и значение отделяются `=`;
|
||||||
- значения не экранируются в v1;
|
- значения не экранируются в v1;
|
||||||
- формат чувствителен к точному префиксу `<SHiNE:`.
|
- канонический новый префикс: `<S:`;
|
||||||
|
- legacy-префикс `<SHiNE:` продолжает поддерживаться при чтении.
|
||||||
|
|
||||||
## 5. Тип `reply`
|
## 5. Тип `reply`
|
||||||
|
|
||||||
Формат:
|
Формат:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
|
<S:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
|
||||||
```
|
```
|
||||||
|
|
||||||
Где поле `id` — это логический идентификатор сообщения:
|
Где поле `id` — это логический идентификатор сообщения:
|
||||||
@@ -91,14 +92,14 @@ fromLogin|toLogin|timeMs|nonce
|
|||||||
- после него может идти обычный текст ответа;
|
- после него может идти обычный текст ответа;
|
||||||
- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения;
|
- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения;
|
||||||
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
|
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
|
||||||
- в таком случае само сообщение отображается как обычный текст без блока `<SHiNE:reply...>`.
|
- в таком случае само сообщение отображается как обычный текст без блока `<S:reply...>`.
|
||||||
|
|
||||||
## 6. Тип `call`
|
## 6. Тип `call`
|
||||||
|
|
||||||
### Успешный звонок
|
### Успешный звонок
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:call;v=1;status=completed;duration=367>
|
<S:call;v=1;status=completed;duration=367>
|
||||||
```
|
```
|
||||||
|
|
||||||
Где:
|
Где:
|
||||||
@@ -108,7 +109,7 @@ fromLogin|toLogin|timeMs|nonce
|
|||||||
### Неуспешный звонок
|
### Неуспешный звонок
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<SHiNE:call;v=1;status=failed;reason=offline>
|
<S:call;v=1;status=failed;reason=offline>
|
||||||
```
|
```
|
||||||
|
|
||||||
Допустимые причины в v1:
|
Допустимые причины в v1:
|
||||||
@@ -130,7 +131,7 @@ fromLogin|toLogin|timeMs|nonce
|
|||||||
|
|
||||||
Официальный UI SHiNE в v1:
|
Официальный UI SHiNE в v1:
|
||||||
|
|
||||||
- скрывает все корректные `<SHiNE:...>` блоки в начале plaintext;
|
- скрывает все корректные `<S:...>` и legacy `<SHiNE:...>` блоки в начале plaintext;
|
||||||
- для `call` строит специальный человекочитаемый текст:
|
- для `call` строит специальный человекочитаемый текст:
|
||||||
- `Звонок: M:SS`
|
- `Звонок: M:SS`
|
||||||
- `Звонок: H:MM:SS`
|
- `Звонок: H:MM:SS`
|
||||||
|
|||||||
+493
-8
@@ -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 { renderToolbar } from './components/toolbar.js';
|
||||||
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
||||||
import { initPwaInstallPromptHandling } from './services/pwa-install-service.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 UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000;
|
||||||
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
|
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
|
||||||
const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/;
|
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 currentCleanup = null;
|
||||||
let pingIntervalId = null;
|
let pingIntervalId = null;
|
||||||
@@ -184,6 +202,9 @@ let hiddenDmAudioUnlocked = false;
|
|||||||
let initialConnectionCompleted = false;
|
let initialConnectionCompleted = false;
|
||||||
let orientationLockInFlight = false;
|
let orientationLockInFlight = false;
|
||||||
let currentChromeCleanup = null;
|
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 CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||||
const GUEST_ALLOWED_PAGES = new Set([
|
const GUEST_ALLOWED_PAGES = new Set([
|
||||||
'start-view',
|
'start-view',
|
||||||
@@ -283,6 +304,13 @@ function createChromeController(showAppChrome) {
|
|||||||
composerNode = null;
|
composerNode = null;
|
||||||
apply();
|
apply();
|
||||||
},
|
},
|
||||||
|
suspend() {
|
||||||
|
clearSlot(topbarEl, '--topbar-height');
|
||||||
|
clearSlot(composerEl, '--composer-height');
|
||||||
|
},
|
||||||
|
resume() {
|
||||||
|
apply();
|
||||||
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
clearSlot(topbarEl, '--topbar-height');
|
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() {
|
async function unlockHiddenDmAudio() {
|
||||||
try {
|
try {
|
||||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||||
@@ -971,6 +1396,7 @@ function renderPageFailureFallback(pageId, error) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screenEl.innerHTML = '';
|
screenEl.innerHTML = '';
|
||||||
|
teardownSwipePreview({ cancelOnly: true });
|
||||||
const wrap = document.createElement('section');
|
const wrap = document.createElement('section');
|
||||||
wrap.className = 'stack';
|
wrap.className = 'stack';
|
||||||
|
|
||||||
@@ -1007,6 +1433,8 @@ function renderPageFailureFallback(pageId, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderApp() {
|
function renderApp() {
|
||||||
|
teardownSwipePreview({ cancelOnly: true });
|
||||||
|
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
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 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') {
|
rememberToolbarRoute(pageId);
|
||||||
currentCleanup();
|
|
||||||
currentCleanup = null;
|
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();
|
const cachedEntry = keepAliveEligible ? keepAliveEntries.get(rootPageId) : null;
|
||||||
currentChromeCleanup = 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 {
|
try {
|
||||||
screenEl.innerHTML = '';
|
screenEl.innerHTML = '';
|
||||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
|
||||||
const chrome = createChromeController(showAppChrome);
|
const chrome = createChromeController(showAppChrome);
|
||||||
currentChromeCleanup = () => chrome.dispose();
|
currentChromeCleanup = () => chrome.dispose();
|
||||||
const screen = page.render({ route, navigate, chrome });
|
const screen = page.render({ route, navigate, chrome });
|
||||||
@@ -1043,6 +1513,19 @@ function renderApp() {
|
|||||||
|
|
||||||
screenEl.append(screen);
|
screenEl.append(screen);
|
||||||
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
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('no-app-chrome', !showAppChrome);
|
||||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||||
@@ -1163,6 +1646,7 @@ async function init() {
|
|||||||
setSessionResetHandler(() => {
|
setSessionResetHandler(() => {
|
||||||
sessionRuntimeStarted = false;
|
sessionRuntimeStarted = false;
|
||||||
startConnectionMonitor();
|
startConnectionMonitor();
|
||||||
|
clearKeepAliveEntries();
|
||||||
if (reconnectIntervalId) {
|
if (reconnectIntervalId) {
|
||||||
window.clearInterval(reconnectIntervalId);
|
window.clearInterval(reconnectIntervalId);
|
||||||
reconnectIntervalId = null;
|
reconnectIntervalId = null;
|
||||||
@@ -1524,6 +2008,7 @@ async function init() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
window.addEventListener('popstate', renderApp);
|
window.addEventListener('popstate', renderApp);
|
||||||
|
installHorizontalTabSwipe();
|
||||||
document.addEventListener('pointerdown', () => {
|
document.addEventListener('pointerdown', () => {
|
||||||
void unlockHiddenDmAudio();
|
void unlockHiddenDmAudio();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { resolveToolbarActive } from '../router.js';
|
import { getToolbarNavigationTarget, resolveToolbarActive } from '../router.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ function getTotalUnreadMessages() {
|
|||||||
|
|
||||||
function navigateWithGuestRules(pageId, navigate) {
|
function navigateWithGuestRules(pageId, navigate) {
|
||||||
if (state.session.isAuthorized) {
|
if (state.session.isAuthorized) {
|
||||||
navigate(pageId);
|
navigate(getToolbarNavigationTarget(pageId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pageId === 'messages-list') {
|
if (pageId === 'messages-list') {
|
||||||
@@ -57,7 +57,7 @@ function navigateWithGuestRules(pageId, navigate) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate(pageId);
|
navigate(getToolbarNavigationTarget(pageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderToolbar(currentPageId, navigate) {
|
export function renderToolbar(currentPageId, navigate) {
|
||||||
@@ -93,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
btn.append(badge);
|
btn.append(badge);
|
||||||
}
|
}
|
||||||
if (item.pageId === 'channels-list') {
|
if (item.pageId === 'channels-list') {
|
||||||
btn.addEventListener('click', () => navigate('channels-list'));
|
btn.addEventListener('click', () => navigate(getToolbarNavigationTarget('channels-list')));
|
||||||
} else {
|
} else {
|
||||||
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { navigateBack } from '../router.js';
|
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||||
import {
|
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) {
|
function buildTargetFromNode(node) {
|
||||||
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
||||||
const blockNumber = Number(node?.messageRef?.blockNumber);
|
const blockNumber = Number(node?.messageRef?.blockNumber);
|
||||||
@@ -1118,6 +1137,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const selector = parseThreadSelector(route);
|
const selector = parseThreadSelector(route);
|
||||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||||
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||||
|
let activeResolvedChannelLabel = channelDisplayName;
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack channels-screen channels-screen--thread';
|
screen.className = 'stack channels-screen channels-screen--thread';
|
||||||
@@ -1126,13 +1146,28 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||||
rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }],
|
rightActions: [],
|
||||||
});
|
});
|
||||||
const threadHeaderButton = header.querySelector('.header-actions .icon-btn');
|
header.classList.add('channel-thread-topbar');
|
||||||
if (threadHeaderButton) {
|
const headerLeft = header.querySelector('.header-left');
|
||||||
threadHeaderButton.classList.add('channel-header-route-btn');
|
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;
|
threadHeaderButton.disabled = true;
|
||||||
|
headerLeft.append(threadHeaderButton);
|
||||||
}
|
}
|
||||||
chrome?.setTopbar(header);
|
chrome?.setTopbar(header);
|
||||||
|
|
||||||
@@ -1418,6 +1453,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
||||||
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
||||||
}
|
}
|
||||||
|
activeResolvedChannelLabel = resolvedChannelLabel;
|
||||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||||
if (threadHeaderButton) {
|
if (threadHeaderButton) {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { navigateBack } from '../router.js';
|
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||||
import {
|
import {
|
||||||
@@ -2036,7 +2035,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
||||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
|||||||
|
|
||||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
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_STORIES = 0;
|
||||||
const CHANNEL_TYPE_PERSONAL = 100;
|
const CHANNEL_TYPE_PERSONAL = 100;
|
||||||
const DIARY_CHANNEL_NAME = 'diary';
|
const DIARY_CHANNEL_NAME = 'diary';
|
||||||
const DIARY_DISPLAY_NAME = 'Дневник';
|
const DIARY_DISPLAY_NAME = 'Дневник';
|
||||||
|
|
||||||
|
const CHANNELS_VIEW_ALL = 'all';
|
||||||
|
const CHANNELS_VIEW_OWNED = 'owned';
|
||||||
|
const CHANNELS_VIEW_FOLLOWING = 'following';
|
||||||
|
|
||||||
function cleanChannelMessagePreview(text) {
|
function cleanChannelMessagePreview(text) {
|
||||||
const parsed = parseMessageAttachments(text);
|
const parsed = parseMessageAttachments(text);
|
||||||
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
||||||
@@ -228,6 +233,26 @@ function normalizeComparableLogin(value) {
|
|||||||
return normalizeLoginInput(value).toLowerCase();
|
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) {
|
function isFollowedUserVisible(targetLogin) {
|
||||||
const expected = normalizeComparableLogin(targetLogin);
|
const expected = normalizeComparableLogin(targetLogin);
|
||||||
if (!expected) return false;
|
if (!expected) return false;
|
||||||
@@ -617,6 +642,11 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
function mapMockGroups() {
|
function mapMockGroups() {
|
||||||
const mapRow = (channel) => ({
|
const mapRow = (channel) => ({
|
||||||
...channel,
|
...channel,
|
||||||
|
sourceBucket: channel.kind === 'subscribed'
|
||||||
|
? 'followedChannels'
|
||||||
|
: channel.kind === 'followed-user-channel'
|
||||||
|
? 'followedUsers'
|
||||||
|
: 'own',
|
||||||
route: makeShineChannelRoute({
|
route: makeShineChannelRoute({
|
||||||
ownerLogin: String(channel.ownerName || 'channel'),
|
ownerLogin: String(channel.ownerName || 'channel'),
|
||||||
ownerBlockchainName: String(channel.ownerName || ''),
|
ownerBlockchainName: String(channel.ownerName || ''),
|
||||||
@@ -668,6 +698,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: rowId,
|
id: rowId,
|
||||||
|
sourceBucket: bucketKey,
|
||||||
route: buildChannelRouteFromSummary(summary, rowId),
|
route: buildChannelRouteFromSummary(summary, rowId),
|
||||||
ownerName: ownerLogin,
|
ownerName: ownerLogin,
|
||||||
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
||||||
@@ -721,6 +752,7 @@ function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: rowId,
|
id: rowId,
|
||||||
|
sourceBucket: 'own',
|
||||||
route: makeShineChannelRoute({
|
route: makeShineChannelRoute({
|
||||||
ownerLogin,
|
ownerLogin,
|
||||||
ownerBlockchainName,
|
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 }) {
|
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
|
||||||
closeChannelMenu(listState, false);
|
closeChannelMenu(listState, false);
|
||||||
|
|
||||||
@@ -1060,7 +1184,11 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
const allChannels = listState.channels || [];
|
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) {
|
if (!filtered.length) {
|
||||||
container.append(renderEmptyState());
|
container.append(renderEmptyState());
|
||||||
@@ -1228,10 +1356,12 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const isGuest = !state.session.isAuthorized;
|
const isGuest = !state.session.isAuthorized;
|
||||||
const listState = {
|
const listState = {
|
||||||
openMenuId: null,
|
openMenuId: null,
|
||||||
|
topMenuCleanup: null,
|
||||||
notificationsState,
|
notificationsState,
|
||||||
revealedCounters: new Set(),
|
revealedCounters: new Set(),
|
||||||
channels: [],
|
channels: [],
|
||||||
menuCleanup: null,
|
menuCleanup: null,
|
||||||
|
viewMode: normalizeChannelsViewMode(route),
|
||||||
};
|
};
|
||||||
|
|
||||||
const contentEl = document.createElement('div');
|
const contentEl = document.createElement('div');
|
||||||
@@ -1274,8 +1404,31 @@ export function render({ navigate, route, chrome }) {
|
|||||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
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);
|
topBarLeft.append(backBtn, topTitle);
|
||||||
topBarRight.append(findChannelBtn, createInMyBtn);
|
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
||||||
topBarEl.append(topBarLeft, topBarRight);
|
topBarEl.append(topBarLeft, topBarRight);
|
||||||
|
|
||||||
const bottomCta = document.createElement('button');
|
const bottomCta = document.createElement('button');
|
||||||
@@ -1284,18 +1437,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
const rerenderList = () => {
|
const rerenderList = () => {
|
||||||
try {
|
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
|
||||||
const expectedPath = '/channels';
|
|
||||||
if (window.location.pathname !== expectedPath) {
|
|
||||||
window.history.replaceState({}, '', expectedPath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore history errors
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTabEmpty = !(listState.channels || []).length;
|
|
||||||
|
|
||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
|
closeTopChannelsMenu(listState);
|
||||||
|
|
||||||
renderListContent({
|
renderListContent({
|
||||||
screen,
|
screen,
|
||||||
@@ -1305,9 +1449,10 @@ export function render({ navigate, route, chrome }) {
|
|||||||
refreshFeed: reloadFeed,
|
refreshFeed: reloadFeed,
|
||||||
});
|
});
|
||||||
|
|
||||||
topTitle.textContent = 'Каналы';
|
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||||
findChannelBtn.style.display = '';
|
findChannelBtn.style.display = '';
|
||||||
createInMyBtn.style.display = '';
|
createInMyBtn.style.display = '';
|
||||||
|
topMenuBtn.style.display = '';
|
||||||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||||
|
|
||||||
updateBottomCta({ button: bottomCta });
|
updateBottomCta({ button: bottomCta });
|
||||||
@@ -1329,6 +1474,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
|
closeTopChannelsMenu(listState);
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ function renderList(container) {
|
|||||||
container.append(card);
|
container.append(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render() {
|
export function render({ chrome } = {}) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack notifications-screen';
|
screen.className = 'stack notifications-screen';
|
||||||
|
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||||
screen.append(renderHeader({ title: 'Уведомления' }));
|
|
||||||
|
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'tabs';
|
tabs.className = 'tabs';
|
||||||
|
|||||||
+62
-2
@@ -1,6 +1,10 @@
|
|||||||
import { parseShineRouteParts } from './services/shine-routes.js';
|
import { parseShineRouteParts } from './services/shine-routes.js';
|
||||||
|
|
||||||
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
|
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([
|
const PRETTY_PATHS = new Map([
|
||||||
['start-view', 'start'],
|
['start-view', 'start'],
|
||||||
['entry-settings-view', 'entry-settings'],
|
['entry-settings-view', 'entry-settings'],
|
||||||
@@ -68,8 +72,8 @@ export const PRE_AUTH_PAGES = [
|
|||||||
'key-storage-view',
|
'key-storage-view',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getRoute() {
|
export function parseRouteFromPath(pathname = '') {
|
||||||
const currentPath = String(window.location.pathname || '').trim();
|
const currentPath = String(pathname || '').trim();
|
||||||
const raw = currentPath
|
const raw = currentPath
|
||||||
.replace(/^\/+/, '')
|
.replace(/^\/+/, '')
|
||||||
.replace(/^index\.html$/i, '')
|
.replace(/^index\.html$/i, '')
|
||||||
@@ -344,15 +348,29 @@ export function getRoute() {
|
|||||||
return { pageId, params: {} };
|
return { pageId, params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRoute() {
|
||||||
|
return parseRouteFromPath(window.location.pathname || '');
|
||||||
|
}
|
||||||
|
|
||||||
export function navigate(path) {
|
export function navigate(path) {
|
||||||
const cleanPath = toPrettyPath(path);
|
const cleanPath = toPrettyPath(path);
|
||||||
const nextPath = cleanPath ? `/${cleanPath}` : '/';
|
const nextPath = cleanPath ? `/${cleanPath}` : '/';
|
||||||
|
if (window.location.pathname !== nextPath) {
|
||||||
|
previousTrackedPath = currentTrackedPath;
|
||||||
|
currentTrackedPath = nextPath;
|
||||||
|
}
|
||||||
if (window.location.pathname !== nextPath) {
|
if (window.location.pathname !== nextPath) {
|
||||||
window.history.pushState({}, '', nextPath);
|
window.history.pushState({}, '', nextPath);
|
||||||
}
|
}
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCurrentPath() {
|
||||||
|
return String(window.location.pathname || '')
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
export function toPrettyPath(path) {
|
export function toPrettyPath(path) {
|
||||||
const raw = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
const raw = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
@@ -397,3 +415,45 @@ export function resolveToolbarActive(pageId) {
|
|||||||
if (pageId === 'user') return 'messages-list';
|
if (pageId === 'user') return 'messages-list';
|
||||||
return 'profile-view';
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
|
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
|
||||||
|
|
||||||
const ATTACH_PREFIX = '<SHiNE:attach;';
|
const ATTACH_BLOCK_RE = /^<(?:SHiNE|S):(attach|att);([^>]*)>\n?/u;
|
||||||
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
|
|
||||||
export const MAX_MESSAGE_ATTACHMENTS = 10;
|
export const MAX_MESSAGE_ATTACHMENTS = 10;
|
||||||
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
|
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
|
||||||
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
|
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
|
||||||
@@ -33,8 +32,8 @@ function normalizeName(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizePreview(input = {}) {
|
function normalizePreview(input = {}) {
|
||||||
const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim();
|
const previewTxId = String(input.preAr || input.previewAr || input.ar || input.txId || '').trim();
|
||||||
const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
const previewSha256Hex = String(input.preSha256 || input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||||
if (!previewTxId || !previewSha256Hex) return null;
|
if (!previewTxId || !previewSha256Hex) return null;
|
||||||
if (!validateArweaveTxId(previewTxId)) return null;
|
if (!validateArweaveTxId(previewTxId)) return null;
|
||||||
if (!validateSha256Hex(previewSha256Hex)) return null;
|
if (!validateSha256Hex(previewSha256Hex)) return null;
|
||||||
@@ -47,9 +46,11 @@ function normalizePreview(input = {}) {
|
|||||||
export function normalizeAttachment(input = {}) {
|
export function normalizeAttachment(input = {}) {
|
||||||
const txId = String(input.ar || input.txId || '').trim();
|
const txId = String(input.ar || input.txId || '').trim();
|
||||||
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||||
const size = Number(input.size || input.sizeBytes || 0);
|
const size = Number(input.sz || input.size || input.sizeBytes || 0);
|
||||||
const name = normalizeName(input.name || input.fileName || 'file');
|
const name = normalizeName(input.nm || input.name || input.fileName || 'file');
|
||||||
const preview = normalizePreview(input.preview || {
|
const preview = normalizePreview(input.preview || {
|
||||||
|
preAr: input.preAr,
|
||||||
|
preSha256: input.preSha256,
|
||||||
previewAr: input.previewAr,
|
previewAr: input.previewAr,
|
||||||
previewSha256: input.previewSha256,
|
previewSha256: input.previewSha256,
|
||||||
});
|
});
|
||||||
@@ -75,9 +76,9 @@ export function buildAttachmentBlock(attachment) {
|
|||||||
const item = normalizeAttachment(attachment);
|
const item = normalizeAttachment(attachment);
|
||||||
const encodedName = encodeURIComponent(item.name);
|
const encodedName = encodeURIComponent(item.name);
|
||||||
const previewFields = item.preview
|
const previewFields = item.preview
|
||||||
? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}`
|
? `;preAr=${item.preview.ar};preSha256=${item.preview.sha256}`
|
||||||
: '';
|
: '';
|
||||||
return `<SHiNE:attach;v=${item.preview ? 2 : 1};name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
|
return `<S:att;v=1;nm=${encodedName};sz=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function composeMessageWithAttachments(text, attachments = []) {
|
export function composeMessageWithAttachments(text, attachments = []) {
|
||||||
@@ -105,16 +106,22 @@ export function parseMessageAttachments(rawText) {
|
|||||||
let rest = String(rawText || '');
|
let rest = String(rawText || '');
|
||||||
const attachments = [];
|
const attachments = [];
|
||||||
|
|
||||||
while (rest.startsWith(ATTACH_PREFIX)) {
|
while (true) {
|
||||||
const match = rest.match(ATTACH_BLOCK_RE);
|
const match = rest.match(ATTACH_BLOCK_RE);
|
||||||
if (!match) break;
|
if (!match) break;
|
||||||
const fields = parseFields(match[1]);
|
const fields = parseFields(match[2]);
|
||||||
try {
|
try {
|
||||||
|
const version = Number(fields.v || 0);
|
||||||
|
if (version !== 1 && version !== 2) {
|
||||||
|
throw new Error('unsupported attachment version');
|
||||||
|
}
|
||||||
attachments.push(normalizeAttachment({
|
attachments.push(normalizeAttachment({
|
||||||
name: decodeURIComponent(String(fields.name || 'file')),
|
nm: decodeURIComponent(String(fields.nm || fields.name || 'file')),
|
||||||
size: fields.size,
|
sz: fields.sz || fields.size,
|
||||||
sha256: fields.sha256,
|
sha256: fields.sha256,
|
||||||
ar: fields.ar,
|
ar: fields.ar,
|
||||||
|
preAr: fields.preAr,
|
||||||
|
preSha256: fields.preSha256,
|
||||||
previewAr: fields.previewAr,
|
previewAr: fields.previewAr,
|
||||||
previewSha256: fields.previewSha256,
|
previewSha256: fields.previewSha256,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ function composeChannelMetaText({ title = '', description = '', avatar = null }
|
|||||||
const cleanTitle = validateChannelMetaTitle(title);
|
const cleanTitle = validateChannelMetaTitle(title);
|
||||||
const cleanDescription = normalizeChannelMetaDescription(description);
|
const cleanDescription = normalizeChannelMetaDescription(description);
|
||||||
const rows = [];
|
const rows = [];
|
||||||
if (cleanTitle) rows.push(`<SHiNE:title;v=1;${cleanTitle}>`);
|
if (cleanTitle) rows.push(`<S:title;v=1;${cleanTitle}>`);
|
||||||
if (avatar?.ar) {
|
if (avatar?.ar) {
|
||||||
const size = Number(avatar.size || 0);
|
const size = Number(avatar.size || 0);
|
||||||
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
|
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 (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
|
||||||
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
|
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 аватара.');
|
if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.');
|
||||||
rows.push(`<SHiNE:avatar;v=1;size=${size};sha256=${sha256};ar=${ar}>`);
|
rows.push(`<S:ava;v=1;sz=${size};sha256=${sha256};ar=${ar}>`);
|
||||||
}
|
}
|
||||||
if (cleanDescription) rows.push(cleanDescription);
|
if (cleanDescription) rows.push(cleanDescription);
|
||||||
return rows.join('\n');
|
return rows.join('\n');
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ function defaultParsed(rawText = '') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasTechPrefix(text = '', offset = 0) {
|
||||||
|
return String(text || '').startsWith('<SHiNE:', offset) || String(text || '').startsWith('<S:', offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTechPrefixLength(text = '', offset = 0) {
|
||||||
|
if (String(text || '').startsWith('<SHiNE:', offset)) return 7;
|
||||||
|
if (String(text || '').startsWith('<S:', offset)) return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
function parseReplyId(value = '') {
|
function parseReplyId(value = '') {
|
||||||
const parts = String(value || '').split('|');
|
const parts = String(value || '').split('|');
|
||||||
if (parts.length !== 4) return null;
|
if (parts.length !== 4) return null;
|
||||||
@@ -57,41 +67,45 @@ function buildCallDisplayText(callSummary) {
|
|||||||
|
|
||||||
export function sanitizeUserDmTextForSend(rawText = '') {
|
export function sanitizeUserDmTextForSend(rawText = '') {
|
||||||
const text = String(rawText || '');
|
const text = String(rawText || '');
|
||||||
return text.startsWith('<SHiNE:') ? `< SHiNE:${text.slice(7)}` : text;
|
if (text.startsWith('<SHiNE:')) return `< SHiNE:${text.slice(7)}`;
|
||||||
|
if (text.startsWith('<S:')) return `< S:${text.slice(3)}`;
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDmCallTechBlock({ status = '', durationSec = 0, reason = '' } = {}) {
|
export function buildDmCallTechBlock({ status = '', durationSec = 0, reason = '' } = {}) {
|
||||||
const cleanStatus = String(status || '').trim().toLowerCase();
|
const cleanStatus = String(status || '').trim().toLowerCase();
|
||||||
if (cleanStatus === 'completed') {
|
if (cleanStatus === 'completed') {
|
||||||
return `<SHiNE:call;v=1;status=completed;duration=${Math.max(0, Math.floor(Number(durationSec || 0)))}>`;
|
return `<S:call;v=1;status=completed;duration=${Math.max(0, Math.floor(Number(durationSec || 0)))}>`;
|
||||||
}
|
}
|
||||||
const cleanReason = String(reason || '').trim().toLowerCase();
|
const cleanReason = String(reason || '').trim().toLowerCase();
|
||||||
return `<SHiNE:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
|
return `<S:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
|
export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
|
||||||
const cleanBaseKey = String(baseKey || '').trim();
|
const cleanBaseKey = String(baseKey || '').trim();
|
||||||
if (!cleanBaseKey) return '';
|
if (!cleanBaseKey) return '';
|
||||||
return `<SHiNE:reply;v=1;id=${cleanBaseKey}>`;
|
return `<S:reply;v=1;id=${cleanBaseKey}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseDmTechBlocks(rawText = '') {
|
export function parseDmTechBlocks(rawText = '') {
|
||||||
const text = String(rawText || '');
|
const text = String(rawText || '');
|
||||||
if (!text.startsWith('<SHiNE:')) return defaultParsed(text);
|
if (!hasTechPrefix(text)) return defaultParsed(text);
|
||||||
|
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
let replyRef = null;
|
let replyRef = null;
|
||||||
let callSummary = null;
|
let callSummary = null;
|
||||||
|
|
||||||
while (text.startsWith('<SHiNE:', cursor)) {
|
while (hasTechPrefix(text, cursor)) {
|
||||||
const end = text.indexOf('>', cursor);
|
const end = text.indexOf('>', cursor);
|
||||||
if (end < 0) {
|
if (end < 0) {
|
||||||
if (!blocks.length) return defaultParsed(text);
|
if (!blocks.length) return defaultParsed(text);
|
||||||
break;
|
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);
|
const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean);
|
||||||
if (!segments.length) {
|
if (!segments.length) {
|
||||||
if (!blocks.length) return defaultParsed(text);
|
if (!blocks.length) return defaultParsed(text);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
.topbar-slot .dm-head,
|
.topbar-slot .dm-head,
|
||||||
.topbar-slot .channels-top-bar {
|
.topbar-slot .channels-top-bar {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
min-height: calc(68px + env(safe-area-inset-top));
|
||||||
|
box-sizing: border-box;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: rgba(14, 21, 35, 0.92);
|
background: rgba(14, 21, 35, 0.92);
|
||||||
border: 1px solid rgba(212, 175, 55, 0.18);
|
border: 1px solid rgba(212, 175, 55, 0.18);
|
||||||
@@ -30,10 +32,22 @@
|
|||||||
|
|
||||||
.topbar-slot .dm-head,
|
.topbar-slot .dm-head,
|
||||||
.topbar-slot .channels-top-bar {
|
.topbar-slot .channels-top-bar {
|
||||||
|
min-height: calc(68px + env(safe-area-inset-top));
|
||||||
padding-top: calc(10px + env(safe-area-inset-top));
|
padding-top: calc(10px + env(safe-area-inset-top));
|
||||||
padding-bottom: 10px;
|
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 {
|
.app-topbar-shell .page-title {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -5065,7 +5079,8 @@ textarea.input {
|
|||||||
|
|
||||||
.channels-top-back-btn,
|
.channels-top-back-btn,
|
||||||
.channels-top-add-btn,
|
.channels-top-add-btn,
|
||||||
.channels-top-search-btn {
|
.channels-top-search-btn,
|
||||||
|
.channels-top-more-btn {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
@@ -5089,6 +5104,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);
|
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 {
|
.channels-search-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -5752,6 +5773,25 @@ textarea.input {
|
|||||||
font-weight: 700;
|
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--channel .page-header .icon-btn,
|
||||||
.channels-screen--thread .page-header .icon-btn {
|
.channels-screen--thread .page-header .icon-btn {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -5761,6 +5801,11 @@ textarea.input {
|
|||||||
transform: translateY(-40%);
|
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--channel .page-header .channel-header-route-btn,
|
||||||
.channels-screen--thread .page-header .channel-header-route-btn {
|
.channels-screen--thread .page-header .channel-header-route-btn {
|
||||||
border: 1px solid rgba(146, 173, 229, 0.38);
|
border: 1px solid rgba(146, 173, 229, 0.38);
|
||||||
@@ -5768,6 +5813,15 @@ textarea.input {
|
|||||||
color: #d9e6ff;
|
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 {
|
.channels-screen--channel .page-header .channel-header-entrypoint-btn {
|
||||||
border: 1px solid rgba(224, 190, 117, 0.38);
|
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));
|
background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34));
|
||||||
|
|||||||
@@ -59,6 +59,124 @@ body::before {
|
|||||||
padding: 14px 14px 24px;
|
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 {
|
.screen-content.no-app-chrome {
|
||||||
top: var(--call-minimized-bar-height, 0px);
|
top: var(--call-minimized-bar-height, 0px);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user