Compare commits

..

94 Commits

Author SHA1 Message Date
7d5980a453 Подгонка regexp к серверной валидации 2026-04-11 21:38:24 +02:00
5193ceb071 Валидация вводимого имени пользователя 2026-04-11 21:35:34 +02:00
7434e16252 Исправление высоты текста у ссылки 2026-04-11 21:05:48 +02:00
c762e527c2 Исправление нестабильности HTTP соединения 2026-04-11 21:03:32 +02:00
3291def79b Исправление устаревшего имени в UserButton 2026-04-11 11:57:12 +02:00
032f92f8a2 Исправление индикатора непрочитанных в диалогах группы при синхронизаци 2026-04-11 11:51:21 +02:00
RoyceDa
f4592d03b0 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m41s
2026-04-10 18:56:13 +02:00
RoyceDa
6554483939 С binary на hex 2026-04-10 18:52:58 +02:00
RoyceDa
ba12db3c72 OPUS сборка 2026-04-10 17:54:48 +02:00
RoyceDa
b596d36543 Базовая версия голосовых сообщений и аудиоплеер. Кодирование OPUS 2026-04-10 17:20:44 +02:00
RoyceDa
93ef692eb5 Подготовка голосовых сообщений 2026-04-09 16:53:57 +02:00
RoyceDa
8fdfe9b786 Исправдение failed to decompress data 2026-04-09 16:36:23 +02:00
RoyceDa
547ac89987 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 4m4s
2026-04-08 23:05:03 +02:00
RoyceDa
130ad9c35a Исправление парсинга emoji и составных emoji (skin tones) 2026-04-08 22:58:49 +02:00
RoyceDa
adfc6add6f Исправление битых вложений в группах (на декодинг) 2026-04-08 21:55:59 +02:00
RoyceDa
cf29cecfd6 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 5m58s
2026-04-04 18:59:52 +02:00
RoyceDa
779c265851 Поднятие версии, outline
All checks were successful
SP Builds / build (push) Successful in 4m25s
2026-04-04 18:34:18 +02:00
RoyceDa
8ac952071d Фикс протокола 2026-04-04 18:23:24 +02:00
RoyceDa
e1f5cb7eb8 Перевод звонка в активную стадию 2026-04-04 18:18:05 +02:00
RoyceDa
30f2c90015 Фикс ассиметричного обмена 2026-04-04 18:07:24 +02:00
RoyceDa
a341aedd8d Фикс бесконечного обмена ключами 2026-04-04 18:04:11 +02:00
RoyceDa
a9164c7087 Фикс обмена ключами 2026-04-04 18:01:52 +02:00
RoyceDa
04dd23dd5c Фикс обмена ключами 2026-04-04 18:01:09 +02:00
RoyceDa
5979c31120 Таймаут вызова 2026-04-04 17:46:38 +02:00
RoyceDa
c8c85991c7 Исправление протокола для правильного END_CALL 2026-04-04 17:19:43 +02:00
RoyceDa
c052fdae41 Реализация нового протокола звонков 2026-04-04 16:48:26 +02:00
RoyceDa
3492a881cc Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m36s
2026-04-02 18:31:46 +02:00
RoyceDa
febeb58778 Фикс ICE кандидатов
Some checks failed
SP Builds / build (push) Has been cancelled
2026-04-02 17:57:38 +02:00
RoyceDa
93e4898bec Правильный deviceId 2026-04-02 17:38:34 +02:00
RoyceDa
de7a00f37a Обновление протокола звонков без авторизации с сохранением защиты 2026-04-02 17:26:38 +02:00
RoyceDa
7b3dd6c566 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m38s
2026-04-01 16:31:31 +02:00
RoyceDa
70af076248 Исправлен цвет аватарки при ее отсутствии в профиле 2026-04-01 16:31:27 +02:00
RoyceDa
92c9dc03c9 Время звонка начинается тогда, когда начинается аудио-дорожка, а не тогда, когда установлено соединение с SFU 2026-04-01 16:28:41 +02:00
RoyceDa
7e8d086a74 Исправление встречных звонков 2026-04-01 14:39:45 +02:00
RoyceDa
0a0c810105 Защита от принятия звонка принятого на другом устройстве 2026-04-01 14:21:11 +02:00
RoyceDa
8fbfb4fa5c Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m43s
2026-03-30 19:53:08 +02:00
RoyceDa
2b9e28ee4a Исправление системных звуков в звонке
Some checks failed
SP Builds / build (push) Has been cancelled
2026-03-30 19:41:06 +02:00
RoyceDa
d2a506119c Исправление невозможности выбора сообщений в диалоге 2026-03-30 19:37:17 +02:00
RoyceDa
269f66fdc5 Улучшение CI/CD 2026-03-29 17:03:30 +02:00
RoyceDa
5113d18d70 Фикс дергания при старте интерфейса
Some checks failed
Linux Kernel Build / build (arm64, arm64) (push) Failing after 2m40s
Linux Kernel Build / build (x64, x86_64) (push) Successful in 3m4s
MacOS Kernel Build / build (arm64) (push) Successful in 6m9s
SP Builds / build (push) Successful in 3m53s
Windows Kernel Build / build (push) Successful in 12m15s
MacOS Kernel Build / build (x64) (push) Successful in 6m56s
2026-03-29 16:09:28 +02:00
RoyceDa
cd2dee21ab Поднятие версии kernel 2026-03-29 16:07:02 +02:00
RoyceDa
1b14463dbb Поднятие версии kernel 2026-03-29 16:06:44 +02:00
RoyceDa
d23ca97be9 Поднятие версии.
Some checks failed
Linux Kernel Build / build (x64, x86_64) (push) Has been cancelled
Linux Kernel Build / build (arm64, arm64) (push) Has been cancelled
MacOS Kernel Build / build (x64) (push) Has been cancelled
MacOS Kernel Build / build (arm64) (push) Has been cancelled
SP Builds / build (push) Has been cancelled
Windows Kernel Build / build (push) Has been cancelled
2026-03-29 15:58:19 +02:00
RoyceDa
519aa8802f Улучшение протокола, чистка кода 2026-03-29 15:45:08 +02:00
RoyceDa
2f2a0b5376 Передача транспортного сервера в контекст, в базу, и в кэш 2026-03-29 14:58:52 +02:00
RoyceDa
61e83bdd43 Унификация кодирования chacha_key_plain теперь он в hex, а не в utf8 с потерей данных 2026-03-28 17:52:13 +02:00
RoyceDa
f5bfa153b6 Приведение chacha_key_plain к hex для транспортировки 2026-03-28 17:20:03 +02:00
RoyceDa
81f5e66c56 Дополнительные доменные зоны 2026-03-28 17:07:51 +02:00
RoyceDa
aaa4b4283a Новая система пересылки сообщений без трансляции и транскодирования вложений 2026-03-28 17:07:43 +02:00
RoyceDa
c9cff515e5 Передача chachakey и начало нового протокола вложений 2026-03-27 20:07:40 +02:00
RoyceDa
94ba139541 Обновление протокола вложений 2026-03-27 15:58:16 +02:00
RoyceDa
7e0e97f472 Новый протокол кодирования и декодирования вложений 2026-03-27 15:32:10 +02:00
RoyceDa
8d6090e632 Новая система вложений 2026-03-26 22:29:03 +02:00
RoyceDa
fd3fac54f6 Новая сериализация, оптимизации приема и парсинга пакетов 2026-03-26 22:28:43 +02:00
RoyceDa
bd3c0eec69 Исправлена возможность позвонить в системный аккаунт 2026-03-24 17:58:28 +02:00
RoyceDa
697b797f8c Исправлен баг интерфейса связанный с долгой загрузкой изображений 2026-03-24 17:57:43 +02:00
RoyceDa
429aa614d7 Исправление CI/CD 2026-03-24 17:29:47 +02:00
RoyceDa
e727529b89 Фикс CMD +, фикс зума, изменен верхний toolbar
Some checks failed
Linux Kernel Build / build (arm64, arm64) (push) Failing after 2m25s
Linux Kernel Build / build (x64, x86_64) (push) Successful in 2m56s
SP Builds / build (push) Successful in 3m35s
MacOS Kernel Build / build (arm64) (push) Successful in 11m54s
Windows Kernel Build / build (push) Successful in 16m16s
MacOS Kernel Build / build (x64) (push) Successful in 8m42s
2026-03-24 17:19:22 +02:00
RoyceDa
786d5428f8 Merge branch 'main' into dev 2026-03-24 16:50:46 +02:00
RoyceDa
e4da2510cc Улучшение CI/CD 2026-03-24 16:35:01 +02:00
RoyceDa
0e5384b908 ci/cd test 2026-03-24 16:19:07 +02:00
013a5d9f17 Обновить .gitea/workflows/linux.yaml 2026-03-24 14:13:09 +00:00
RoyceDa
f997581c23 Исправление вложения звонка 2026-03-24 16:11:42 +02:00
RoyceDa
1333eb40ce Улучшение CI/CD 2026-03-24 16:11:27 +02:00
RoyceDa
57be4631f2 CI/CD без кэша 2026-03-22 20:08:19 +02:00
RoyceDa
7c2718ff9a Merge branch 'dev' into main
All checks were successful
SP Builds / build (push) Successful in 14m6s
2026-03-22 19:59:52 +02:00
RoyceDa
40b2d9c3f0 Улучшение CI/CD 2026-03-22 19:59:15 +02:00
RoyceDa
13d52c694f Поднятие версии 2026-03-22 19:58:26 +02:00
RoyceDa
27f011ec61 Merge branch 'dev' into main
Some checks failed
SP Builds / build (push) Has been cancelled
2026-03-22 19:56:47 +02:00
RoyceDa
7e977b762f Правильная расстановка иконок 2026-03-22 19:52:24 +02:00
RoyceDa
6dc35d7cca Новое вложение ЗВОНОК теперь отправляется от лица звонящего 2026-03-22 19:49:59 +02:00
RoyceDa
a1c8b3d95a Трансляция Emoji из Unicode к общему виду 2026-03-22 19:21:11 +02:00
RoyceDa
d2e574d186 CI/CD без рестарта докера 2026-03-22 18:28:28 +02:00
RoyceDa
2e9ccf9c6e Merge branch 'dev' into main 2026-03-22 17:30:50 +02:00
RoyceDa
1eca665a62 CI/CD 2026-03-22 17:19:58 +02:00
RoyceDa
319ff7baf1 Merge branch 'main' of https://git.rosetta.im/Rosetta/desktop into main 2026-03-22 16:44:59 +02:00
RoyceDa
e61c9c5f58 Merge branch 'dev' into main 2026-03-22 16:43:57 +02:00
RoyceDa
426f0c40bc Проблема обмена ключами в звонках 2026-03-22 16:41:09 +02:00
RoyceDa
b300fa4d03 Улучшен CI/CD 2026-03-22 16:08:24 +02:00
04cd27a5f3 Обновить .gitea/workflows/service-packs.yaml 2026-03-21 19:41:14 +00:00
d29fe317a8 Merge pull request '1.1.2-1.5.3' (#20) from dev into main
Some checks failed
SP Builds / build (push) Has been cancelled
Reviewed-on: #20
2026-03-21 19:37:20 +00:00
RoyceDa
329e6d7825 Улучшенный CI/CD 2026-03-21 21:33:24 +02:00
RoyceDa
bd3411de52 Поднятие версии 2026-03-21 21:31:57 +02:00
RoyceDa
6f95f326bf Поднятие версии 2026-03-21 21:30:39 +02:00
RoyceDa
91b955d621 События звонков в сообщениях 2026-03-21 21:28:20 +02:00
RoyceDa
e019702dbe Новый тип вложений - Attachment.CALL с активными звонками 2026-03-21 21:18:09 +02:00
RoyceDa
48e0cddbaa Фикс звонков самому себе 2026-03-21 19:39:47 +02:00
RoyceDa
98fbabc130 Финальная часть после тестов скорости шифрования кадров (frames) 2026-03-21 19:37:52 +02:00
RoyceDa
0c823c398f Шифрование тест 4 2026-03-21 19:21:56 +02:00
RoyceDa
1d6c30fb08 Шифрование тест 3 2026-03-21 19:16:44 +02:00
RoyceDa
0d70824d77 Шифрование тест 2 2026-03-21 19:03:43 +02:00
RoyceDa
4df39cb83d WASM для ускорения шифрования звонков, тест 2026-03-21 18:51:39 +02:00
RoyceDa
f269046c46 Отладка производительности 2026-03-20 22:25:33 +02:00
RoyceDa
5032d92f8e WASM ускоренный алгоритм шифрования для избежания backpressure 2026-03-20 21:24:36 +02:00
48 changed files with 2924 additions and 847 deletions

View File

@@ -1,4 +1,5 @@
name: MacOS Kernel Build
run-name: Build and Upload MacOS Kernel
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
on:
@@ -12,6 +13,10 @@ on:
jobs:
build:
runs-on: macos
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -30,6 +35,7 @@ jobs:
restore-keys: |
${{ runner.os }}-npm-
if-no-files-found: ignore
# Кэш для electron-builder
- name: Cache electron-builder
uses: actions/cache@v5
@@ -41,32 +47,36 @@ jobs:
restore-keys: |
${{ runner.os }}-electron-builder-
if-no-files-found: ignore
- name: NPM offline setup
shell: bash
run: |
mkdir -p dist/builds/darwin/arm64 dist/builds/darwin/x64
npm config set cache "$HOME/.npm-cache" --global
npm config set prefer-offline true --global
- name: Install npm dependencies
run: npm install --prefer-offline --no-audit --no-fund
- name: Build the application
run: npm run kernel:mac
#Загружаем на удаленный сервер по SSH используя scp и пароль из секретов
#Загружаем из двух папок dist/builds/darwin/x64 и dist/builds/darwin/arm64, так как electron-builder может создавать разные файлы для разных архитектур
#Вызываем файл sshupload.sh и передаем ему параметры из секретов, чтобы не хранить пароль в открытом виде в workflow
- name: Upload to SSH using scp
shell: bash
run: |
chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
-l "$GITHUB_WORKSPACE/dist/builds/darwin/x64/Rosetta-*.pkg" \
-r "${{ secrets.SDU_SSH_KERNEL }}/darwin/x64" \
-s "${{ secrets.SDU_SSH_HOST }}" \
-u "${{ secrets.SDU_SSH_USERNAME }}" \
-p '${{ secrets.SDU_SSH_PASSWORD }}'
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
-l "$GITHUB_WORKSPACE/dist/builds/darwin/arm64/Rosetta-*.pkg" \
-r "${{ secrets.SDU_SSH_KERNEL }}/darwin/arm64" \
-s "${{ secrets.SDU_SSH_HOST }}" \
-u "${{ secrets.SDU_SSH_USERNAME }}" \
-p '${{ secrets.SDU_SSH_PASSWORD }}'
npx electron-vite build
npx electron-builder --mac --${{ matrix.arch }}
- name: Check if files exist
run: |
echo "=== Checking dist structure ==="
find dist/builds/darwin/${{ matrix.arch }} -type f -name "*.pkg" 2>/dev/null || echo "No PKG files found"
ls -la dist/builds/darwin/${{ matrix.arch }}/ 2>/dev/null || echo "arch folder not found"
- name: Upload to SSH using SCP
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SDU_SSH_HOST }}
username: ${{ secrets.SDU_SSH_USERNAME }}
password: ${{ secrets.SDU_SSH_PASSWORD }}
port: 22
source: "dist/builds/darwin/${{ matrix.arch }}/Rosetta-*.pkg"
target: "${{ secrets.SDU_SSH_KERNEL }}/darwin/${{ matrix.arch }}"
strip_components: 4
rm: true

View File

@@ -1,85 +1,64 @@
name: Linux Kernel Build
run-name: Build and Upload Linux Kernel
#Запускаем только кнопкой "Run workflow" в Actions
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'lib/**'
- 'lib/**'
jobs:
build:
runs-on: linux
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- arch: x64
out_dir: x86_64
- arch: arm64
out_dir: arm64
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Cache npm dependencies
uses: actions/cache@v5
with:
path: ~/.npm
key: ${{ runner.os }}-linux-npm-${{ hashFiles('**/package.json') }}
restore-keys: |
${{ runner.os }}-linux-npm-
if-no-files-found: ignore
- name: Cache electron-builder
uses: actions/cache@v5
with:
path: |
~/.cache/electron
key: ${{ runner.os }}-linux-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
restore-keys: |
${{ runner.os }}-linux-electron-builder-
if-no-files-found: ignore
- name: NPM offline setup
run: |
npm config set cache ~/.npm --global
npm config set prefer-offline true --global
- name: Install npm dependencies
run: npm install --no-audit --no-fund
- name: Debug ARCH
run: |
echo "arch=${{ matrix.arch }}"
echo "out_dir=${{ matrix.out_dir }}"
- name: Build the application
run: npm run kernel:linux
run: |
mkdir -p dist/builds/linux/x64
mkdir -p dist/builds/linux/${{ matrix.out_dir }}
npx electron-vite build
npx electron-builder --linux --${{ matrix.arch }}
- name: Check if files exist
run: |
echo "=== Checking dist structure ==="
find dist/builds -type f -name "*.AppImage" 2>/dev/null || echo "No AppImage files found"
ls -la dist/builds/linux/ 2>/dev/null || echo "linux folder not found"
find dist/builds/linux/${{ matrix.out_dir }} -type f -name "*.AppImage" 2>/dev/null || echo "No AppImage files found"
ls -la dist/builds/linux/${{ matrix.out_dir }}/ 2>/dev/null || echo "arch folder not found"
- name: Install SCP in Docker container
run: apt-get install -y openssh-client
- name: Upload x64 to SSH using SCP
- name: Upload to SSH using SCP
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SDU_SSH_HOST }}
username: ${{ secrets.SDU_SSH_USERNAME }}
password: ${{ secrets.SDU_SSH_PASSWORD }}
port: 22
source: "dist/builds/linux/x64/Rosetta-*.AppImage"
target: "${{ secrets.SDU_SSH_KERNEL }}/linux/x64"
source: dist/builds/linux/${{ matrix.out_dir }}/Rosetta-*.AppImage
target: ${{ secrets.SDU_SSH_KERNEL }}/linux/${{ matrix.arch }}
strip_components: 4
rm: true
- name: Upload arm64 to SSH using SCP
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SDU_SSH_HOST }}
username: ${{ secrets.SDU_SSH_USERNAME }}
password: ${{ secrets.SDU_SSH_PASSWORD }}
port: 22
source: "dist/builds/linux/arm64/Rosetta-*.AppImage"
target: "${{ secrets.SDU_SSH_KERNEL }}/linux/arm64"
strip_components: 4
rm: true
rm: true

View File

@@ -1,6 +1,6 @@
name: SP Builds
run-name: Build and Upload SP Packages
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
on:
workflow_dispatch:
push:
@@ -11,35 +11,14 @@ on:
jobs:
build:
runs-on: macos
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
# Кэш npm (тарифы грузятся из ~/.npm-cache на macOS)
- name: Cache npm cache
uses: actions/cache@v5
with:
path: ${{ env.HOME }}/.npm-cache
key: ${{ runner.os }}-npm-${{ hashFiles('**/package.json') }}
restore-keys: |
${{ runner.os }}-npm-
if-no-files-found: ignore
# Кэш для electron-builder (macOS)
- name: Cache electron-builder (macOS)
uses: actions/cache@v5
with:
path: |
${{ env.HOME }}/Library/Caches/electron-builder
${{ env.HOME }}/Library/Caches/electron
key: ${{ runner.os }}-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
restore-keys: |
${{ runner.os }}-electron-builder-
if-no-files-found: ignore
node-version: '22'
- name: Checkout code
uses: actions/checkout@v6
- name: NPM offline setup
shell: bash
@@ -54,6 +33,11 @@ jobs:
- name: Build the application
run: npm run kernel:linux
- name: Install ZIP in Docker container
run: |
apt-get update
apt-get install -y zip
#Собираем сервисные пакеты для всех платформ
- name: Build SP
shell: bash
@@ -62,13 +46,46 @@ jobs:
sh "$GITHUB_WORKSPACE/build-packs.sh"
#Загружаем на удаленный сервер по SSH используя scp и пароль из секретов
#Загружаем из двух папок dist/builds/darwin/x64 и dist/builds/darwin/arm64, так как electron-builder может создавать разные файлы для разных архитектур
- name: Upload to SSH
# - name: Upload to SSH
# shell: bash
# run: |
# chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
# sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
# -l "$GITHUB_WORKSPACE/packs/*" \
# -r "${{ secrets.SDU_SSH_PACKS }}" \
# -s "${{ secrets.SDU_SSH_HOST }}" \
# -u "${{ secrets.SDU_SSH_USERNAME }}" \
# -p '${{ secrets.SDU_SSH_PASSWORD }}'
- name: Check SP
shell: bash
run: |
chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
-l "$GITHUB_WORKSPACE/packs/*" \
-r "${{ secrets.SDU_SSH_PACKS }}" \
-s "${{ secrets.SDU_SSH_HOST }}" \
-u "${{ secrets.SDU_SSH_USERNAME }}" \
-p '${{ secrets.SDU_SSH_PASSWORD }}'
echo "=== Workspace ==="
pwd
ls -la
echo "=== Packs ==="
find packs -maxdepth 3 -type f 2>/dev/null || true
test -n "$(find packs -type f -print -quit 2>/dev/null)" || { echo "packs is empty"; exit 1; }
- name: Clean files before upload
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SDU_SSH_HOST }}
username: ${{ secrets.SDU_SSH_USERNAME }}
password: ${{ secrets.SDU_SSH_PASSWORD }}
port: 22
script: |
mkdir -p "${{ secrets.SDU_SSH_PACKS }}"
find "${{ secrets.SDU_SSH_PACKS }}" -mindepth 1 -type f -delete
- name: Upload to SSH using SCP
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SDU_SSH_HOST }}
username: ${{ secrets.SDU_SSH_USERNAME }}
password: ${{ secrets.SDU_SSH_PASSWORD }}
port: 22
source: "packs/*"
target: "${{ secrets.SDU_SSH_PACKS }}"
strip_components: 1
rm: false

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: sshupload.sh -l <local_glob> -r <remote_dir> -s <server> -u <user> -p <password>
EOF
}
local_glob=""
remote_dir=""
server=""
user=""
password=""
while [[ $# -gt 0 ]]; do
case "$1" in
-l|--local) local_glob="$2"; shift 2;;
-r|--remote) remote_dir="$2"; shift 2;;
-s|--server) server="$2"; shift 2;;
-u|--user) user="$2"; shift 2;;
-p|--password) password="$2"; shift 2;;
-h|--help) usage; exit 0;;
*) echo "Unknown arg: $1" >&2; usage; exit 1;;
esac
done
if [[ -z "$local_glob" || -z "$remote_dir" || -z "$server" || -z "$user" || -z "$password" ]]; then
echo "Missing required params" >&2
usage
exit 1
fi
# Ensure sshpass installed
if ! command -v sshpass >/dev/null 2>&1; then
if command -v brew >/dev/null 2>&1; then
brew update
brew install hudochenkov/sshpass/sshpass
elif command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y sshpass
else
echo "sshpass not found and no supported package manager" >&2
exit 1
fi
fi
user_host="${user}@${server}"
# Ensure remote dir exists and clear it
sshpass -p "$password" ssh -o StrictHostKeyChecking=no "$user_host" "mkdir -p '$remote_dir' && rm -f '$remote_dir'/*"
# Expand glob (supports ~ and patterns) and upload each file (compatible with macOS bash 3.x)
shopt -s nullglob
eval "files=( ${local_glob} )"
shopt -u nullglob
if [[ ${#files[@]} -eq 0 ]]; then
echo "No files matched: $local_glob" >&2
exit 1
fi
for f in "${files[@]}"; do
sshpass -p "$password" scp -o StrictHostKeyChecking=no "$f" "$user_host:$remote_dir/"
done
echo "Upload completed"

View File

@@ -1,4 +1,5 @@
name: Windows Kernel Build
run-name: Build and Upload Windows Kernel
#Запускаем только кнопкой "Run workflow" в Actions -> Build Windows
#Или если есть коммпит в папку lib в ветке main

View File

@@ -22,6 +22,7 @@ import { DialogStateProvider } from './providers/DialogStateProvider.tsx/DialogS
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
import { PlayerProvider } from './providers/PlayerProvider/PlayerProvider';
window.Buffer = Buffer;
export default function App() {
@@ -58,19 +59,21 @@ export default function App() {
<Topbar></Topbar>
<ContextMenuProvider>
<ImageViwerProvider>
<AvatarProvider>
<Routes>
<Route path="/" element={
getViewByLoginState()
} />
<Route path="/create-seed" element={<CreateSeed />} />
<Route path="/confirm-seed" element={<ConfirmSeed />} />
<Route path="/set-password" element={<SetPassword />} />
<Route path="/main/*" element={<Main />} />
<Route path="/exists-seed" element={<ExistsSeed />} />
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
</Routes>
</AvatarProvider>
<PlayerProvider>
<AvatarProvider>
<Routes>
<Route path="/" element={
getViewByLoginState()
} />
<Route path="/create-seed" element={<CreateSeed />} />
<Route path="/confirm-seed" element={<ConfirmSeed />} />
<Route path="/set-password" element={<SetPassword />} />
<Route path="/main/*" element={<Main />} />
<Route path="/exists-seed" element={<ExistsSeed />} />
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
</Routes>
</AvatarProvider>
</PlayerProvider>
</ImageViwerProvider>
</ContextMenuProvider>
</Box>

View File

@@ -60,7 +60,7 @@ export function ActionAvatar(props : ActionAvatarProps) {
size={120}
radius={120}
mx="auto"
bg={'#fff'}
bg={avatars.length > 0 ? '#fff' : undefined}
name={props.title.trim() || props.publicKey}
color={'initials'}
src={avatars.length > 0 ?

View File

@@ -135,11 +135,13 @@ export function ChatHeader() {
</Flex>
</Flex>
<Flex h={'100%'} align={'center'} gap={'sm'}>
<IconPhone
{publicKey != opponent.publicKey && !isSystemAccount && (
<IconPhone
onClick={() => call(dialog)}
style={{
cursor: 'pointer'
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconPhone>
)}
<IconTrashX
onClick={onClickClearMessages}
style={{

View File

@@ -1,7 +1,7 @@
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react";
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMicrophone, IconMoodSmile, IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
@@ -25,7 +25,8 @@ import { AnimatedButton } from "../AnimatedButton/AnimatedButton";
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
import { MentionList, Mention } from "../MentionList/MentionList";
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
import { useVoiceMessage } from "./useVoiceMessage";
import { VoiceRecorder } from "../VoiceRecorder/VoiceRecorder";
export function DialogInput() {
const colors = useRosettaColors();
@@ -47,6 +48,7 @@ export function DialogInput() {
const [mentionList, setMentionList] = useState<Mention[]>([]);
const mentionHandling = useRef<string>("");
const {getDraft, saveDraft} = useDrafts(dialog);
const {start, stop, isRecording, duration, waves, getAudioBlob, interpolateCompressWaves} = useVoiceMessage();
const avatars = useAvatars(
@@ -64,6 +66,15 @@ export function DialogInput() {
}]
], [], true);
const hasText = message.trim().length > 0;
const showSendIcon = hasText || attachments.length > 0 || isRecording;
const onMicroClick = () => {
if(!isRecording) {
start();
}
};
const fileDialog = useFileDialog({
multiple: false,
//naccept: '*',
@@ -89,7 +100,11 @@ export function DialogInput() {
blob: fileContent,
id: generateRandomKey(8),
type: AttachmentType.FILE,
preview: files[0].size + "::" + files[0].name
preview: files[0].size + "::" + files[0].name,
transport: {
transport_server: "",
transport_tag: ""
}
}]);
}
});
@@ -116,7 +131,11 @@ export function DialogInput() {
type: AttachmentType.MESSAGES,
id: generateRandomKey(8),
blob: JSON.stringify([...replyMessages.messages]),
preview: ""
preview: "",
transport: {
transport_server: "",
transport_tag: ""
}
}]);
if(editableDivRef.current){
editableDivRef.current.focus();
@@ -180,8 +199,28 @@ export function DialogInput() {
mentionHandling.current = username;
}
const send = () => {
if(blocked || (message.trim() == "" && attachments.length <= 0)) {
const send = async () => {
if(blocked || (message.trim() == "" && attachments.length <= 0 && !isRecording)){
return;
}
if(isRecording){
const audioBlob = getAudioBlob();
stop();
if(!audioBlob){
return;
}
sendMessage("", [
{
blob: Buffer.from(await audioBlob.arrayBuffer()).toString('hex'),
id: generateRandomKey(8),
type: AttachmentType.VOICE,
preview: duration + "::" + interpolateCompressWaves(35).join(","),
transport: {
transport_server: "",
transport_tag: ""
}
}
]);
return;
}
sendMessage(message, attachments);
@@ -230,7 +269,11 @@ export function DialogInput() {
blob: avatars[0].avatar,
id: generateRandomKey(8),
type: AttachmentType.AVATAR,
preview: ""
preview: "",
transport: {
transport_server: "",
transport_tag: ""
}
}]);
if(editableDivRef.current){
editableDivRef.current.focus();
@@ -270,7 +313,11 @@ export function DialogInput() {
blob: base64Image,
id: attachmentId,
type: AttachmentType.IMAGE,
preview: ""
preview: "",
transport: {
transport_server: "",
transport_tag: ""
}
}]);
}
if(editableDivRef.current){
@@ -304,7 +351,11 @@ export function DialogInput() {
blob: fileContent,
id: attachmentId,
type: AttachmentType.FILE,
preview: files[0].size + "::" + files[0].name
preview: files[0].size + "::" + files[0].name,
transport: {
transport_server: "",
transport_tag: ""
}
}]);
}
@@ -345,79 +396,118 @@ export function DialogInput() {
{!blocked &&
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
<Flex w={25} mt={10} justify={'center'}>
<Menu width={150} withArrow>
<Menu.Target>
<IconPaperclip stroke={1.5} style={{
cursor: 'pointer'
}} size={25} color={colors.chevrons.active}></IconPaperclip>
</Menu.Target>
<Menu.Dropdown style={{
userSelect: 'none'
}}>
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
<Menu.Item fz={'xs'} fw={500} leftSection={
<IconFile size={14}></IconFile>
} onClick={onClickPaperclip}>File</Menu.Item>
{((avatars.length > 0 && !hasGroup(dialog))
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
&& <Menu.Item fz={'xs'} fw={500} leftSection={
<IconCamera size={14}></IconCamera>
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
</Menu.Dropdown>
</Menu>
{isRecording && (
<IconTrash onClick={stop} style={{
cursor: 'pointer'
}} color={colors.error} stroke={1.5} size={25}></IconTrash>
)}
{!isRecording && (
<Menu width={150} withArrow>
<Menu.Target>
<IconPaperclip stroke={1.5} style={{
cursor: 'pointer'
}} size={25} color={colors.chevrons.active}></IconPaperclip>
</Menu.Target>
<Menu.Dropdown style={{
userSelect: 'none'
}}>
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
<Menu.Item fz={'xs'} fw={500} leftSection={
<IconFile size={14}></IconFile>
} onClick={onClickPaperclip}>File</Menu.Item>
{((avatars.length > 0 && !hasGroup(dialog))
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
&& <Menu.Item fz={'xs'} fw={500} leftSection={
<IconCamera size={14}></IconCamera>
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
</Menu.Dropdown>
</Menu>
)}
</Flex>
<Flex
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
align={'center'}
>
<RichTextInput
ref={editableDivRef}
style={{
border: 0,
minHeight: 45,
fontSize: 14,
background: 'transparent',
width: '100%',
paddingLeft: 10,
paddingRight: 10,
outline: 'none',
paddingTop: 10,
paddingBottom: 8
}}
placeholder="Type message..."
autoFocus
//ref={textareaRef}
//onPaste={onPaste}
//maxLength={2500}
//w={'100%'}
//h={'100%'}
onKeyDown={handleKeyDown}
onChange={setMessage}
onPaste={onPaste}
//dangerouslySetInnerHTML={{__html: message}}
></RichTextInput>
{!isRecording && <>
<RichTextInput
ref={editableDivRef}
style={{
border: 0,
minHeight: 45,
fontSize: 14,
background: 'transparent',
width: '100%',
paddingLeft: 10,
paddingRight: 10,
outline: 'none',
paddingTop: 10,
paddingBottom: 8
}}
placeholder="Type message..."
autoFocus
onKeyDown={handleKeyDown}
onChange={setMessage}
onPaste={onPaste}
></RichTextInput>
</>}
{isRecording && <>
<VoiceRecorder duration={duration} waves={waves}></VoiceRecorder>
</>}
</Flex>
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
<Popover withArrow>
<Popover.Target>
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
cursor: 'pointer'
}}></IconMoodSmile>
</Popover.Target>
<Popover.Dropdown p={0}>
<EmojiPicker
onEmojiClick={onEmojiClick}
searchDisabled
skinTonesDisabled
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
/>
</Popover.Dropdown>
</Popover>
<IconSend stroke={1.5} color={message.trim() == "" && attachments.length <= 0 ? colors.chevrons.active : colors.brandColor} onClick={send} style={{
cursor: 'pointer'
}} size={25}></IconSend>
{!isRecording && <>
<Popover withArrow>
<Popover.Target>
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
cursor: 'pointer'
}}></IconMoodSmile>
</Popover.Target>
<Popover.Dropdown p={0}>
<EmojiPicker
onEmojiClick={onEmojiClick}
searchDisabled
skinTonesDisabled
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
/>
</Popover.Dropdown>
</Popover>
</>}
<Box pos="relative" ml={isRecording ? 35 : 0} w={25} h={25}>
<Transition mounted={showSendIcon} transition="pop" duration={180} timingFunction="ease">
{(styles) => (
<IconSend
stroke={1.5}
color={colors.brandColor}
onClick={send}
style={{
...styles,
position: 'absolute',
inset: 0,
cursor: 'pointer'
}}
size={25}
/>
)}
</Transition>
<Transition mounted={!showSendIcon} transition="pop" duration={180} timingFunction="ease">
{(styles) => (
<IconMicrophone
stroke={1.5}
color={colors.chevrons.active}
onClick={onMicroClick}
style={{
...styles,
position: 'absolute',
inset: 0,
cursor: 'pointer'
}}
size={25}
/>
)}
</Transition>
</Box>
</Flex>
</Flex>}
{blocked && <Box mih={62} bg={colors.boxColor}>

View File

@@ -0,0 +1,273 @@
import { useState, useRef, useCallback, useEffect } from "react";
export function useVoiceMessage(): {
isRecording: boolean;
isPaused: boolean;
duration: number;
waves: number[];
start: () => Promise<void>;
stop: () => void;
pause: () => void;
play: () => void;
error: string | null;
getAudioBlob: () => Blob | null;
interpolateCompressWaves: (targetLength: number) => number[];
} {
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [duration, setDuration] = useState(0);
const [waves, setWaves] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const waveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const waveDataRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const stopWaveLoop = useCallback(() => {
if (waveTimerRef.current) {
clearInterval(waveTimerRef.current);
waveTimerRef.current = null;
}
}, []);
const startTimer = useCallback(() => {
if (timerRef.current) return;
timerRef.current = setInterval(() => {
setDuration((prev) => prev + 1);
}, 1000);
}, []);
const startWaveLoop = useCallback(() => {
stopWaveLoop();
const analyser = analyserRef.current;
if (!analyser) return;
if (!waveDataRef.current || waveDataRef.current.length !== analyser.frequencyBinCount) {
waveDataRef.current = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount));
}
const MAX_WAVES = 120;
const tick = () => {
if (!analyserRef.current || !waveDataRef.current) return;
analyserRef.current.getByteFrequencyData(waveDataRef.current);
let peak = 0;
for (let i = 0; i < waveDataRef.current.length; i++) {
const v = waveDataRef.current[i];
if (v > peak) peak = v;
}
const bar = peak / 255;
setWaves((prev) => {
const next = [...prev, bar];
return next.length > MAX_WAVES ? next.slice(next.length - MAX_WAVES) : next;
});
};
tick();
waveTimerRef.current = setInterval(tick, 300);
}, [stopWaveLoop]);
const cleanupAudio = useCallback(() => {
stopWaveLoop();
sourceRef.current?.disconnect();
sourceRef.current = null;
analyserRef.current = null;
waveDataRef.current = null;
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}, [stopWaveLoop]);
const start = useCallback(async () => {
try {
setError(null);
setDuration(0);
setWaves([]);
chunksRef.current = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0;
analyser.minDecibels = -100;
analyser.maxDecibels = -10;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
audioContextRef.current = audioContext;
analyserRef.current = analyser;
sourceRef.current = source;
// Выбираем лучший поддерживаемый кодек
const preferredTypes = [
"audio/webm;codecs=opus",
"audio/ogg;codecs=opus",
"audio/webm",
];
const mimeType = preferredTypes.find((t) => MediaRecorder.isTypeSupported(t)) ?? "";
const mediaRecorder = new MediaRecorder(stream, {
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 32_000,
});
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
cleanupAudio();
};
mediaRecorder.start(100);
setIsRecording(true);
setIsPaused(false);
startTimer();
startWaveLoop();
} catch (err) {
setError("Could not start voice recording. Please check microphone permissions.");
console.error("Voice recording error:", err);
}
}, [startTimer, startWaveLoop, cleanupAudio]);
const stop = useCallback(() => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
mediaRecorderRef.current = null;
setIsRecording(false);
setIsPaused(false);
clearTimer();
stopWaveLoop();
}
}, [isRecording, clearTimer, stopWaveLoop]);
const pause = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
mediaRecorderRef.current.pause();
setIsPaused(true);
clearTimer();
stopWaveLoop();
}
}, [clearTimer, stopWaveLoop]);
const play = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "paused") {
mediaRecorderRef.current.resume();
setIsPaused(false);
startTimer();
startWaveLoop();
}
}, [startTimer, startWaveLoop]);
useEffect(() => {
return () => {
clearTimer();
stopWaveLoop();
if (mediaRecorderRef.current && mediaRecorderRef.current?.state !== "inactive") {
mediaRecorderRef.current.stop();
}
cleanupAudio();
};
}, [clearTimer, stopWaveLoop, cleanupAudio]);
const getAudioBlob = useCallback((): Blob | null => {
if (chunksRef.current.length === 0) return null;
const mimeType = mediaRecorderRef.current?.mimeType ?? "audio/webm;codecs=opus";
return new Blob(chunksRef.current, { type: mimeType });
}, []);
const interpolateCompressWaves = useCallback((targetLength: number) => {
if (targetLength <= 0) return [];
if (waves.length === 0) return Array(targetLength).fill(0);
if (waves.length === targetLength) return waves;
if (waves.length > targetLength) {
const compressed: number[] = [];
const bucketSize = waves.length / targetLength;
for (let i = 0; i < targetLength; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
let max = 0;
for (let j = start; j < end && j < waves.length; j++) {
if (waves[j] > max) max = waves[j];
}
compressed.push(max);
}
return compressed;
}
if (targetLength === 1) return [waves[0]];
const stretched: number[] = [];
const lastSourceIndex = waves.length - 1;
for (let i = 0; i < targetLength; i++) {
const position = (i * lastSourceIndex) / (targetLength - 1);
const left = Math.floor(position);
const right = Math.min(Math.ceil(position), lastSourceIndex);
if (left === right) {
stretched.push(waves[left]);
continue;
}
const t = position - left;
const value = waves[left] * (1 - t) + waves[right] * t;
stretched.push(value);
}
return stretched;
}, [waves]);
return {
isRecording,
isPaused,
duration,
waves,
start,
stop,
pause,
play,
error,
getAudioBlob,
interpolateCompressWaves,
};
}

View File

@@ -17,6 +17,8 @@ import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu";
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
import { useMentions } from "@/app/providers/DialogStateProvider.tsx/useMentions";
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
export interface DialogProps extends DialogRow {
onClickDialog: (dialog: string) => void;
@@ -54,6 +56,7 @@ export function GroupDialog(props : DialogProps) {
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
const {openContextMenu} = useDialogContextMenu();
const {isMentioned} = useMentions();
const [protocolState] = useProtocolState();
useEffect(() => {
@@ -156,7 +159,7 @@ export function GroupDialog(props : DialogProps) {
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
)}
{unreaded > 0 && !lastMessageFromMe && !isMentioned(props.dialog_id) && <Badge
{unreaded > 0 && !lastMessageFromMe && protocolState != ProtocolState.SYNCHRONIZATION && !isMentioned(props.dialog_id) && <Badge
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
c={isInCurrentDialog ? colors.brandColor : 'white'}
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}

View File

@@ -8,6 +8,8 @@ import { ErrorBoundaryProvider } from "@/app/providers/ErrorBoundaryProvider/Err
import { AttachmentError } from "../AttachmentError/AttachmentError";
import { MessageAvatar } from "./MessageAvatar";
import { MessageProps } from "../Messages/Message";
import { MessageCall } from "./MessageCall";
import { MessageVoice } from "./MessageVoice";
export interface MessageAttachmentsProps {
attachments: Attachment[];
@@ -51,6 +53,10 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
return <MessageFile {...attachProps} key={index}></MessageFile>
case AttachmentType.AVATAR:
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
case AttachmentType.CALL:
return <MessageCall {...attachProps} key={index}></MessageCall>
case AttachmentType.VOICE:
return <MessageVoice {...attachProps} key={index}></MessageVoice>
default:
return <AttachmentError key={index}></AttachmentError>;
}

View File

@@ -0,0 +1,62 @@
import { useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
import { AttachmentProps } from "./MessageAttachments";
import { Avatar, Box, Flex, Text } from "@mantine/core";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { IconPhoneIncoming, IconPhoneOutgoing, IconX } from "@tabler/icons-react";
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
export function MessageCall(props: AttachmentProps) {
const {
getPreview,
} =
useAttachment(
props.attachment,
props.parent,
);
const publicKey = usePublicKey();
const preview = getPreview();
const caller = props.parent.from == publicKey;
const duration = parseInt(preview);
const colors = useRosettaColors();
const error = duration == 0;
return (
<Box p={'sm'} style={{
background: colors.mainColor,
border: '1px solid ' + colors.borderColor,
borderRadius: 8,
minWidth: 200,
minHeight: 60
}}>
<Flex gap={'sm'} direction={'row'}>
<Avatar bg={error ? colors.error : colors.brandColor} size={40}>
{!error && <>
{!caller && (
<IconPhoneIncoming color={'white'} size={22}></IconPhoneIncoming>
)}
{caller && (
<IconPhoneOutgoing color={'white'} size={22}></IconPhoneOutgoing>
)}
</>}
{error && <>
<IconX color={'white'} size={22}></IconX>
</>}
</Avatar>
<Flex direction={'column'} gap={5}>
<Text size={'sm'}>{
error ? (!caller ? "Missed call" : "Rejected call") : (!caller ? "Incoming call" : "Outgoing call")
}</Text>
{!error &&
<Text size={'xs'} c={colors.chevrons.active}>
{translateDurationToTime(duration)}
</Text>
}
{error && <Text size={'xs'} c={colors.error}>
Call was not answered or was rejected
</Text>}
</Flex>
</Flex>
</Box>
);
}

View File

@@ -29,6 +29,7 @@ export function MessageImage(props: AttachmentProps) {
const [blurhashPreview, setBlurhashPreview] = useState("");
useEffect(() => {
console.info(props.attachment);
console.info("Consturcting image, download status: " + downloadStatus);
constructBlob();
constructFromBlurhash();
@@ -149,7 +150,7 @@ export function MessageImage(props: AttachmentProps) {
</Box>
</Flex>
</Portal>}
{(props.delivered == DeliveredMessageState.ERROR || (props.delivered != DeliveredMessageState.DELIVERED &&
{(props.delivered == DeliveredMessageState.ERROR || error || (props.delivered != DeliveredMessageState.DELIVERED &&
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
)) && (
<Overlay center h={'100%'} radius={8} color="#000" opacity={0.85}>
@@ -158,7 +159,7 @@ export function MessageImage(props: AttachmentProps) {
)}
{(downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.DOWNLOADING) && (<Flex direction={'column'} pos={'absolute'} top={0} justify={'center'} h={'100%'} align={'center'} gap={'xs'}>
{!error && (
{!error && downloadStatus == DownloadStatus.DOWNLOADING && (
<Box style={{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 50,
@@ -168,11 +169,20 @@ export function MessageImage(props: AttachmentProps) {
justifyContent: 'center',
alignItems: 'center',
}}>
{downloadPercentage > 0 ? (
<AnimatedRoundedProgress size={40} value={downloadPercentage} color="white"></AnimatedRoundedProgress>
) : (
<IconArrowDown size={25} color={'white'} />
)}
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} color="white"></AnimatedRoundedProgress>
</Box>
)}
{!error && downloadStatus != DownloadStatus.DOWNLOADING && (
<Box style={{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: 50,
height: 40,
width: 40,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}>
<IconArrowDown size={25} color={'white'} />
</Box>
)}
{error && (

View File

@@ -5,6 +5,7 @@ import { ReplyedMessage } from "../ReplyedMessage/ReplyedMessage";
import { IconX } from "@tabler/icons-react";
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
import { modals } from "@mantine/modals";
import { MessageReply } from "@/app/providers/DialogProvider/useReplyMessages";
export function MessageReplyMessages(props: AttachmentProps) {
const colors = useRosettaColors();
@@ -12,9 +13,7 @@ export function MessageReplyMessages(props: AttachmentProps) {
('showAlertInReplyMessages', true);
const [bgInReplyMessages] = useSetting<string>
('bgInReplyMessages', '');
const reply = JSON.parse(props.attachment.blob);
//console.info("Mreply", reply);
const reply = JSON.parse(props.attachment.blob) as MessageReply[];
const closeAlert = () => {
modals.openConfirmModal({
@@ -40,8 +39,8 @@ export function MessageReplyMessages(props: AttachmentProps) {
{reply.length <= 0 &&
<Skeleton h={50} w={'100%'}></Skeleton>
}
{reply.map((msg, index) => (
<ReplyedMessage parent={props.parent} chacha_key_plain={props.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
{reply.map((msg : MessageReply, index) => (
<ReplyedMessage parent={props.parent} chacha_key_plain={msg.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
))}
{showAlertInReplyMessages && <Alert style={{
borderTopLeftRadius: 0,

View File

@@ -0,0 +1,267 @@
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
import { AttachmentProps } from "./MessageAttachments";
import { Avatar, Box, Flex, Text, useMantineTheme } from "@mantine/core";
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
import { IconArrowDown, IconPlayerPauseFilled, IconPlayerPlayFilled, IconX } from "@tabler/icons-react";
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { useMemo, useRef } from "react";
import { usePlayerContext } from "@/app/providers/PlayerProvider/usePlayerContext";
const WAVE_BARS = 40;
const BAR_WIDTH = 2;
const BAR_GAP = 2;
const MIN_BAR_HEIGHT = 4;
const MAX_BAR_HEIGHT = 24;
function normalizeWaves(source: number[], targetLength: number): number[] {
if (targetLength <= 0) return [];
if (source.length === 0) return Array(targetLength).fill(0);
if (source.length === targetLength) return source;
if (source.length > targetLength) {
const compressed: number[] = [];
const bucketSize = source.length / targetLength;
for (let i = 0; i < targetLength; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
let max = 0;
for (let j = start; j < end && j < source.length; j++) {
if (source[j] > max) max = source[j];
}
compressed.push(max);
}
return compressed;
}
if (targetLength === 1) return [source[0]];
const stretched: number[] = [];
const lastSourceIndex = source.length - 1;
for (let i = 0; i < targetLength; i++) {
const position = (i * lastSourceIndex) / (targetLength - 1);
const left = Math.floor(position);
const right = Math.min(Math.ceil(position), lastSourceIndex);
if (left === right) {
stretched.push(source[left]);
continue;
}
const t = position - left;
stretched.push(source[left] * (1 - t) + source[right] * t);
}
return stretched;
}
function formatTime(seconds: number) {
const s = Math.max(0, Math.floor(seconds));
const m = Math.floor(s / 60).toString().padStart(2, "0");
const r = (s % 60).toString().padStart(2, "0");
return `${m}:${r}`;
}
export function MessageVoice(props: AttachmentProps) {
const { downloadPercentage, downloadStatus, uploadedPercentage, download, getPreview } = useAttachment(
props.attachment,
props.parent,
);
const theme = useMantineTheme();
const colors = useRosettaColors();
const preview = getPreview() || "";
const [durationPart = "0", wavesPart = ""] = preview.split("::");
const previewDuration = Number.parseInt(durationPart, 10) || 0;
const rawWaves = useMemo(
() =>
wavesPart
.split(",")
.map((s) => Number.parseFloat(s))
.filter((n) => Number.isFinite(n) && n >= 0),
[wavesPart]
);
const waves = useMemo(() => normalizeWaves(rawWaves, WAVE_BARS), [rawWaves]);
const peak = useMemo(() => {
const max = Math.max(...waves, 0);
return max > 0 ? max : 1;
}, [waves]);
const isUploading =
props.delivered === DeliveredMessageState.WAITING &&
uploadedPercentage > 0 &&
uploadedPercentage < 100;
const error = downloadStatus === DownloadStatus.ERROR;
const waveformWidth = WAVE_BARS * BAR_WIDTH + (WAVE_BARS - 1) * BAR_GAP;
const waveformRef = useRef<HTMLDivElement | null>(null);
const {
playAudio,
pause,
duration: currentDuration,
playing,
setDuration,
totalDuration,
currentMessageId,
} = usePlayerContext();
const messageId = String((props.parent as any)?.id ?? (props.attachment as any)?.messageId ?? props.attachment.id);
const isCurrentTrack = currentMessageId === messageId;
const fullDuration = Math.max(isCurrentTrack && totalDuration > 0 ? totalDuration : previewDuration, 1);
const safeCurrent = isCurrentTrack ? currentDuration : 0;
const playbackProgress = Math.max(0, Math.min(1, safeCurrent / fullDuration));
const createAudioBlob = () => new Blob([Buffer.from(props.attachment.blob, "hex")], { type: "audio/webm;codecs=opus" });
const ensureStarted = (seekToSec?: number) => {
const blob = createAudioBlob();
playAudio("Voice Message", "", blob, messageId);
if (typeof seekToSec === "number") {
requestAnimationFrame(() => setDuration(seekToSec));
}
};
const handleMainAction = () => {
if (error) return;
if (downloadStatus !== DownloadStatus.DOWNLOADED) {
download();
return;
}
if (!isCurrentTrack) {
ensureStarted();
return;
}
if (playing) {
pause();
return;
}
ensureStarted(Math.max(0, safeCurrent));
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (error || downloadStatus !== DownloadStatus.DOWNLOADED) return;
const rect = waveformRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
const x = e.clientX - rect.left;
const progress = Math.max(0, Math.min(1, x / rect.width));
const seekTo = progress * fullDuration;
if (!isCurrentTrack) {
ensureStarted(seekTo);
return;
}
setDuration(seekTo);
};
const timeText =
isCurrentTrack && safeCurrent > 0
? `-${formatTime(Math.max(0, fullDuration - safeCurrent))}`
: formatTime(fullDuration);
return (
<Flex gap="sm" align="center">
<Avatar
bg={error ? colors.error : colors.brandColor}
size={40}
style={{ cursor: "pointer", position: "relative" }}
onClick={handleMainAction}
>
{!error && (
<>
{downloadStatus === DownloadStatus.DOWNLOADING && (
<div style={{ position: "absolute", top: 0, left: 0 }}>
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} />
</div>
)}
{isUploading && (
<div style={{ position: "absolute", top: 0, left: 0 }}>
<AnimatedRoundedProgress color="#fff" size={40} value={uploadedPercentage} />
</div>
)}
{downloadStatus !== DownloadStatus.DOWNLOADED && <IconArrowDown color="white" size={22} />}
{downloadStatus === DownloadStatus.DOWNLOADED && !isUploading &&
(isCurrentTrack && playing ? (
<IconPlayerPauseFilled color="white" size={22} />
) : (
<IconPlayerPlayFilled color="white" size={22} />
))}
</>
)}
{(error || isUploading) && <IconX color="white" size={22} />}
</Avatar>
<Flex direction="column">
<Box
ref={waveformRef}
w={waveformWidth}
h={32}
onClick={handleSeek}
style={{ overflow: "hidden", cursor: "pointer" }}
>
<Flex h="100%" align="center" gap={BAR_GAP} wrap="nowrap">
{waves.map((value, index) => {
const normalized = Math.max(0, Math.min(1, value / peak));
const height = Math.max(
MIN_BAR_HEIGHT,
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
);
const passed = playbackProgress * waves.length - index;
const fillPercent = Math.max(0, Math.min(1, passed));
const inactiveColor = theme.colors.gray[4];
const activeColor = colors.brandColor;
let background = inactiveColor;
if (fillPercent >= 1) {
background = activeColor;
} else if (fillPercent > 0) {
background = `linear-gradient(90deg, ${activeColor} 0%, ${activeColor} ${fillPercent * 100}%, ${inactiveColor} ${fillPercent * 100}%, ${inactiveColor} 100%)`;
}
return (
<Box
key={index}
w={BAR_WIDTH}
h={height}
style={{
flex: `0 0 ${BAR_WIDTH}px`,
borderRadius: 999,
background,
}}
/>
);
})}
</Flex>
</Box>
<Text size="xs" c="dimmed">
{timeText}
</Text>
</Flex>
</Flex>
);
}

View File

@@ -103,7 +103,11 @@ export function Message(props: MessageProps) {
publicKey: user.publicKey,
message: props.message,
attachments: props.attachments.filter(a => a.type != AttachmentType.MESSAGES),
message_id: props.message_id
message_id: props.message_id,
/**
* Кодируем в hex чтобы было удобнее передавать по сети
*/
chacha_key_plain: props.chacha_key_plain
};
const avatars = useAvatars(user.publicKey);
@@ -125,6 +129,9 @@ export function Message(props: MessageProps) {
if (props.replyed) {
return false;
}
if(props.chacha_key_plain == ""){
return false;
}
if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) {
return false;
}

View File

@@ -24,6 +24,8 @@ export interface SettingsInputDefaultProps {
mt?: StyleProp<MantineSpacing>;
rightSection?: ReactNode;
type?: HTMLInputTypeAttribute;
onErrorStateChange?: (error: boolean) => void;
regexp?: RegExp;
}
export interface SettingsInputGroupProps {
mt?: StyleProp<MantineSpacing>;
@@ -260,7 +262,6 @@ function SettingsInputClickable(
function SettingsInputDefault(props : SettingsInputDefaultProps) {
const colors = useRosettaColors();
const input = useRef<any>(undefined);
const onClick = (e : MouseEvent) => {
e.stopPropagation();
if(!props.disabled){
@@ -268,6 +269,19 @@ function SettingsInputDefault(props : SettingsInputDefaultProps) {
return;
}
}
const onChange = (e) => {
let value = e.target.value;
if(props.regexp && !props.regexp.test(value)) {
props.onErrorStateChange && props.onErrorStateChange(true);
props.onChange && props.onChange(e);
}else{
props.onErrorStateChange && props.onErrorStateChange(false);
console.info('fa');
props.onChange && props.onChange(e);
}
}
return (<>
<Paper mt={props.mt} style={props.style} withBorder styles={{
root: {
@@ -298,7 +312,7 @@ function SettingsInputDefault(props : SettingsInputDefaultProps) {
{!props.rightSection && (
<Input type={props.type} defaultValue={!props.onChange ? props.value : undefined} value={!props.onChange ? undefined : props.value} ref={input} disabled={props.disabled} onClick={(e) => {
onClick(e)
}} onChange={props.onChange} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
}} onChange={onChange} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
input: classes.input
}} placeholder={props.placeholder}></Input>)
}

View File

@@ -42,6 +42,20 @@ export function TextParser(props: TextParserProps) {
const theme = useMantineTheme();
let entityCount = 0;
const UNICODE_EMOJI_SEQUENCE_REGEX =
/(?:\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
const UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL = new RegExp(
UNICODE_EMOJI_SEQUENCE_REGEX.source,
"gu"
);
const toUnified = (value: string): string =>
Array.from(value)
.map((ch) => ch.codePointAt(0)?.toString(16))
.filter(Boolean)
.join("-");
const formatRules : FormatRule[] = [
{
pattern: [
@@ -58,7 +72,7 @@ export function TextParser(props: TextParserProps) {
return <Anchor style={{
userSelect: 'auto',
color: props.__reserved_2 ? theme.colors.blue[2] : undefined
}} fz={14} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
}} fz={13} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
},
flush: (match: string) => {
return <>{match}</>;
@@ -119,6 +133,23 @@ export function TextParser(props: TextParserProps) {
return <>{match}</>;
}
},
{
// unicode emojis (including composite sequences)
pattern: [UNICODE_EMOJI_SEQUENCE_REGEX],
render: (match: string) => {
const textWithoutEmojis = props.text.replace(UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL, "");
const unified = toUnified(match);
if (textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
return <Emoji size={40} unified={unified}></Emoji>;
}
return <Emoji unified={unified}></Emoji>;
},
flush: (match: string) => {
return <Emoji unified={toUnified(match)}></Emoji>;
}
},
{
// :emoji_code:
pattern: [/:emoji_([a-zA-Z0-9_-]+):/],

View File

@@ -5,15 +5,23 @@ import { useNavigate } from 'react-router-dom';
import { useUserInformation } from '@/app/providers/InformationProvider/useUserInformation';
import { usePublicKey } from '@/app/providers/AccountProvider/usePublicKey';
import { useAvatars } from '@/app/providers/AvatarProvider/useAvatars';
import { useEffect } from 'react';
export function UserButton() {
const navigate = useNavigate();
const publicKey = usePublicKey();
const [userInfo] = useUserInformation(publicKey);
const [userInfo, _, forceUpdateUserInformation] = useUserInformation(publicKey);
const avatars = useAvatars(publicKey);
const loading = userInfo.publicKey !== publicKey;
useEffect(() => {
/**
* Обновляем информацию о пользователе принудительно при рендере левого меню
*/
forceUpdateUserInformation();
}, []);
return (
<UnstyledButton p={'sm'} className={classes.user} onClick={() => navigate("/main/profile/me")}>
<Group>

View File

@@ -0,0 +1,278 @@
import { Box, Flex, Text, useMantineTheme } from "@mantine/core";
import { useEffect, useRef, useState } from "react";
interface VoiceRecorderProps {
duration: number;
waves: number[];
}
type AnimatedBar = {
id: number;
value: number;
entered: boolean;
};
const VISIBLE_BARS = 50;
const BAR_WIDTH = 3;
const BAR_GAP = 2;
const STEP_PX = BAR_WIDTH + BAR_GAP;
const COMPONENT_HEIGHT = 45;
const MAX_BAR_HEIGHT = 28;
const MIN_BAR_HEIGHT = 4;
export function VoiceRecorder(props: VoiceRecorderProps) {
const theme = useMantineTheme();
const [bars, setBars] = useState<AnimatedBar[]>([]);
const [subShift, setSubShift] = useState(0);
const prevLengthRef = useRef(0);
const prevWavesRef = useRef<number[]>([]);
const idRef = useRef(0);
const enterFrameRef = useRef<number | null>(null);
const scrollFrameRef = useRef<number | null>(null);
const lastAppendAtRef = useRef<number | null>(null);
const appendIntervalRef = useRef(120);
const barsLengthRef = useRef(0);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const secs = (seconds % 60).toString().padStart(2, "0");
return `${mins}:${secs}`;
};
useEffect(() => {
barsLengthRef.current = bars.length;
}, [bars.length]);
useEffect(() => {
if (props.waves.length === 0) {
setBars([]);
setSubShift(0);
prevLengthRef.current = 0;
prevWavesRef.current = [];
lastAppendAtRef.current = null;
return;
}
if (props.waves.length < prevLengthRef.current) {
const resetBars = props.waves.slice(-VISIBLE_BARS).map((value) => ({
id: idRef.current++,
value,
entered: true,
}));
setBars(resetBars);
setSubShift(0);
prevLengthRef.current = props.waves.length;
prevWavesRef.current = props.waves;
lastAppendAtRef.current = performance.now();
return;
}
const prevWaves = prevWavesRef.current;
let appended: number[] = [];
// Обычный режим: длина выросла
if (props.waves.length > prevLengthRef.current) {
appended = props.waves.slice(prevLengthRef.current);
} else if (props.waves.length === prevLengthRef.current && props.waves.length > 0) {
// Rolling buffer: длина та же, но данные сдвигаются
let changed = false;
if (prevWaves.length !== props.waves.length) {
changed = true;
} else {
for (let i = 0; i < props.waves.length; i++) {
if (props.waves[i] !== prevWaves[i]) {
changed = true;
break;
}
}
}
if (changed) {
appended = [props.waves[props.waves.length - 1]];
}
}
if (appended.length > 0) {
const now = performance.now();
if (lastAppendAtRef.current != null) {
const dt = now - lastAppendAtRef.current;
const perBar = dt / appended.length;
appendIntervalRef.current = appendIntervalRef.current * 0.7 + perBar * 0.3;
}
lastAppendAtRef.current = now;
setSubShift(0);
const newIds: number[] = [];
setBars((prev) => {
const next = [...prev];
appended.forEach((value) => {
const id = idRef.current++;
newIds.push(id);
next.push({
id,
value,
entered: false,
});
});
return next.slice(-VISIBLE_BARS);
});
if (enterFrameRef.current) {
cancelAnimationFrame(enterFrameRef.current);
}
enterFrameRef.current = requestAnimationFrame(() => {
setBars((prev) => {
const ids = new Set(newIds);
return prev.map((bar) => (ids.has(bar.id) ? { ...bar, entered: true } : bar));
});
});
}
prevLengthRef.current = props.waves.length;
prevWavesRef.current = props.waves;
}, [props.waves]);
useEffect(() => {
const tick = () => {
const startedAt = lastAppendAtRef.current;
if (startedAt != null) {
const elapsed = performance.now() - startedAt;
const interval = Math.max(16, appendIntervalRef.current);
const progress = Math.min(1, elapsed / interval);
const smoothShift = barsLengthRef.current >= VISIBLE_BARS ? -progress * STEP_PX : 0;
setSubShift(smoothShift);
} else {
setSubShift(0);
}
scrollFrameRef.current = requestAnimationFrame(tick);
};
scrollFrameRef.current = requestAnimationFrame(tick);
return () => {
if (scrollFrameRef.current) {
cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
useEffect(() => {
return () => {
if (enterFrameRef.current) {
cancelAnimationFrame(enterFrameRef.current);
}
if (scrollFrameRef.current) {
cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const waveformWidth = VISIBLE_BARS * BAR_WIDTH + (VISIBLE_BARS - 1) * BAR_GAP;
return (
<Flex
direction="row"
h={COMPONENT_HEIGHT}
mih={COMPONENT_HEIGHT}
mah={COMPONENT_HEIGHT}
align="center"
justify="center"
gap="xs"
px={6}
>
<Text size="xs" c="dimmed" w={36}>
{formatDuration(props.duration)}
</Text>
<Box
w={waveformWidth}
h={COMPONENT_HEIGHT}
style={{
overflow: "hidden",
}}
>
<Flex
h="100%"
align="center"
gap={BAR_GAP}
wrap="nowrap"
style={{ transform: `translateX(${subShift}px)` }}
>
{Array.from({ length: VISIBLE_BARS }).map((_, index) => {
const bar = bars[index];
if (!bar) {
return (
<Box
key={`empty-${index}`}
w={BAR_WIDTH}
h={MIN_BAR_HEIGHT}
style={{
flex: `0 0 ${BAR_WIDTH}px`,
borderRadius: 999,
background: theme.colors.gray[3],
opacity: 0.22,
}}
/>
);
}
const normalized = Math.max(0, Math.min(1, bar.value));
const height = Math.max(
MIN_BAR_HEIGHT,
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
);
const isLast = index === bars.length - 1;
const isNearTail = index >= bars.length - 3;
return (
<Box
key={bar.id}
w={BAR_WIDTH}
h={height}
style={{
flex: `0 0 ${BAR_WIDTH}px`,
alignSelf: "center",
borderRadius: 999,
background: isLast
? `linear-gradient(180deg, ${theme.colors.blue[3]} 0%, ${theme.colors.blue[5]} 100%)`
: `linear-gradient(180deg, ${theme.colors.blue[4]} 0%, ${theme.colors.blue[6]} 100%)`,
boxShadow: isLast
? `0 0 10px ${theme.colors.blue[4]}55`
: isNearTail
? `0 0 6px ${theme.colors.blue[4]}22`
: "none",
transform: bar.entered ? "scaleY(1)" : "scaleY(0.18)",
transformOrigin: "center center",
transition: [
"height 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
"transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
"opacity 220ms ease",
"box-shadow 220ms ease",
].join(", "),
willChange: "height, transform, opacity",
}}
/>
);
})}
</Flex>
</Box>
</Flex>
);
}

View File

@@ -57,5 +57,7 @@ export const ALLOWED_DOMAINS_ZONES = [
'fm',
'tv',
'im',
'sc'
'sc',
'su',
'by'
];

View File

@@ -27,17 +27,16 @@ export enum DownloadStatus {
}
export function useAttachment(attachment: Attachment, parentMessage: MessageProps) {
const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
const uploadedPercentage = useUploadStatus(attachment.id);
const downloadPercentage = useDownloadStatus(attachment.id);
const [downloadStatus, setDownloadStatus] = useMemory("attachment-downloaded-status-" + attachment.id, DownloadStatus.PENDING, true);
const [downloadTag, setDownloadTag] = useState("");
const [downloadTag, setDownloadTag] = useState(attachment.transport.transport_tag || "");
const {readFile, writeFile, fileExists, size} = useFileStorage();
const { downloadFile } = useTransport();
const publicKey = usePublicKey();
const privatePlain = usePrivatePlain();
const {updateAttachmentInDialogCache} = useDialogsCache();
const {info} = useConsoleLogger('useAttachment');
const { info, error } = useConsoleLogger('useAttachment');
const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
const {getDownloadsPath} = useCore();
const {hasGroup} = useGroups();
@@ -50,36 +49,26 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
}, []);
const getPreview = () => {
if(attachment.preview.split("::")[0].match(uuidRegex)){
/**
* Это тег загрузки
*/
return attachment.preview.split("::").splice(1).join("::");
}
return attachment.preview;
}
const calcDownloadStatus = async () => {
if(attachment.preview.split("::")[0].match(uuidRegex)){
/**
* Это тег загрузки
*/
setDownloadTag(attachment.preview.split("::")[0]);
}
if(!attachment.preview.split("::")[0].match(uuidRegex)){
/**
* Там не тег загрузки, значит это наш файл
*/
setDownloadStatus(DownloadStatus.DOWNLOADED);
console.info("ds", attachment);
if (downloadStatus == DownloadStatus.DOWNLOADED) {
return;
}
if (downloadStatus == DownloadStatus.DOWNLOADED) {
if(attachment.transport.transport_tag == ""){
/**
* Транспортного тега нет только у сообщений отправленных нами, значит он точно наш
*/
setDownloadStatus(DownloadStatus.DOWNLOADED);
return;
}
if(attachment.type == AttachmentType.FILE){
/**
* Если это файл, то он хранится не в папке медиа,
* а в загрузках
* а в загрузках, статус скачивания определяем не только по названию файла,
* но и по его размеру (если размеры и название совпало, то считаем файл скаченным)
*/
const preview = getPreview();
const filesize = parseInt(preview.split("::")[0]);
@@ -89,6 +78,9 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
const exists = await fileExists(pathInDownloads, false);
const existsLength = await size(pathInDownloads, false);
if(exists && existsLength == filesize){
/**
* Если название файла и его размер совпадают (и он существует), то считаем его скаченным
*/
setDownloadStatus(DownloadStatus.DOWNLOADED);
return;
}
@@ -143,14 +135,14 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
let downloadedBlob = '';
try {
downloadedBlob = await downloadFile(attachment.id,
downloadTag);
downloadTag, attachment.transport.transport_server);
} catch (e) {
console.info(e);
info("Error downloading attachment: " + attachment.id);
error("Error downloading attachment: " + attachment.id);
setDownloadStatus(DownloadStatus.ERROR);
return;
}
setDownloadStatus(DownloadStatus.DECRYPTING);
console.info("decoding with key " + parentMessage.chacha_key_plain);
//console.info("Decrypted attachment ", Buffer.from(keyPlain, 'binary').toString('hex'));
const decrypted = await decodeWithPassword(parentMessage.chacha_key_plain, downloadedBlob);
setDownloadTag("");

View File

@@ -0,0 +1,240 @@
import { encodeWithPassword } from "@/app/workers/crypto/crypto";
import { MessageReply } from "../DialogProvider/useReplyMessages";
import { Attachment, AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
import { base64ImageToBlurhash } from "@/app/workers/image/image";
import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "@/app/constants";
import { useContext, useRef } from "react";
import { useTransport } from "../TransportProvider/useTransport";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useDatabase } from "../DatabaseProvider/useDatabase";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
import { AttachmentMeta, DialogContext } from "../DialogProvider/DialogProvider";
import { useTransportServer } from "../TransportProvider/useTransportServer";
export function usePrepareAttachment() {
const intervalsRef = useRef<NodeJS.Timeout>(null);
const {uploadFile} = useTransport();
const {updateDialog} = useDialogsList();
const {runQuery, getQuery} = useDatabase();
const {info, error} = useConsoleLogger('usePrepareAttachment');
const {getDialogCache} = useDialogsCache();
const context = useContext(DialogContext);
const transportServer = useTransportServer();
const updateTimestampInDialogCache = (dialog : string, message_id: string) => {
const dialogCache = getDialogCache(dialog);
if(dialogCache == null){
return;
}
for(let i = 0; i < dialogCache.length; i++){
if(dialogCache[i].message_id == message_id){
dialogCache[i].timestamp = Date.now();
break;
}
}
}
/**
* Обновляет транспортный сервер в кэше, чтобы поддерживать его в актуальном состоянии после загрузки
*/
const updateAttachmentTransportInCache = (dialog: string, message_id : string, attachment: Attachment) => {
const dialogCache = getDialogCache(dialog);
if(dialogCache == null){
return;
}
for(let i = 0; i < dialogCache.length; i++){
if(dialogCache[i].message_id == message_id){
for(let j = 0; j < dialogCache[i].attachments.length; j++){
if(dialogCache[i].attachments[j].id == attachment.id){
dialogCache[i].attachments[j].transport = attachment.transport;
}
}
}
}
}
/**
* Обновляет транспорт в базе после загрузки вложения (нам нужно сохранить транспорт)
*/
const updateAttachmentTransportInDatabase = async (message_id : string, attachment: Attachment) => {
let message = await getQuery(`SELECT attachments FROM messages WHERE message_id = ?`, [message_id]);
console.info(message)
if(!message){
return;
}
if(message.attachments == '[]'){
return;
}
let meta : AttachmentMeta[] = JSON.parse(message.attachments);
for(let i = 0; i < meta.length; i++){
if(meta[i].id == attachment.id){
meta[i].transport = attachment.transport;
}
}
await runQuery(`UPDATE messages SET attachments = ? WHERE message_id = ?`, [JSON.stringify(meta), message_id]);
}
/**
* Обновляет вложение в стейте сообщений
*/
const updateAttachmentTransportInContext = (message_id: string, attachment : Attachment) => {
if(context == null || !context){
/**
* Если этот диалог сейчас не открыт
*/
return;
}
context.setMessages((prev) => {
return prev.map((value) => {
if(value.message_id != message_id){
return value;
}
for(let i = 0; i < value.attachments.length; i++){
if(value.attachments[i].id != attachment.id){
return value;
}
value.attachments[i].transport = attachment.transport;
return value;
}
return value;
})
});
}
const updateTransportAfterUploading = async (dialog: string, message_id : string, attachment: Attachment) => {
updateAttachmentTransportInCache(dialog, message_id, attachment);
updateAttachmentTransportInDatabase(message_id, attachment);
updateAttachmentTransportInContext(message_id, attachment);
}
/**
* Обновляет временную метку в сообщении, пока вложения отправляются,
* потому что если этого не делать, то сообщение может быть помечено как
* не доставленное из-за таймаута доставки
* @param attachments Вложения
*/
const doTimestampUpdateImMessageWhileAttachmentsSend = (message_id: string, dialog: string) => {
if(intervalsRef.current){
clearInterval(intervalsRef.current);
}
intervalsRef.current = setInterval(async () => {
/**
* Обновляем время в левом меню
*/
await runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), message_id]);
updateDialog(dialog);
/**
* Обновляем состояние в кэше диалогов
*/
updateTimestampInDialogCache(dialog, message_id);
if(context == null || !context){
/**
* Если этот диалог сейчас не открыт
*/
return;
}
context.setMessages((prev) => {
return prev.map((value) => {
if(value.message_id != message_id){
return value;
}
return {
...value,
timestamp: Date.now()
};
})
});
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
}
/**
* Подготавливает вложения для отправки. Подготовка
* состоит в загрузке файлов на транспортный сервер, мы не делаем
* это через WebSocket из-за ограничений по размеру сообщений,
* а так же из-за надежности доставки файлов через HTTP
* @param attachments Attachments to prepare for sending
*/
const prepareAttachmentsToSend = async (message_id: string, dialog: string, password: string, attachments : Attachment[]) : Promise<Attachment[]> => {
if(attachments.length <= 0){
return [];
}
let prepared : Attachment[] = [];
try{
for(let i = 0; i < attachments.length; i++){
const attachment : Attachment = attachments[i];
if(attachment.type == AttachmentType.CALL){
/**
* Звонк загружать не надо, по этому просто отправляем его как есть, там нет blob
*/
prepared.push(attachment);
continue;
}
if(attachment.type == AttachmentType.MESSAGES){
let reply : MessageReply[] = JSON.parse(attachment.blob);
for(let j = 0; j < reply.length; j++){
for(let k = 0; k < reply[j].attachments.length; k++){
reply[j].attachments[k].blob = "";
}
}
prepared.push({
...attachment,
blob: await encodeWithPassword(password, JSON.stringify(reply))
});
continue;
}
if((attachment.type == AttachmentType.IMAGE
|| attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
/**
* Загружаем превью blurhash для изображения
*/
const blurhash = await base64ImageToBlurhash(attachment.blob);
attachment.preview = blurhash;
}
doTimestampUpdateImMessageWhileAttachmentsSend(message_id, dialog);
const content = await encodeWithPassword(password, attachment.blob);
const upid = attachment.id;
info(`Uploading attachment with upid: ${upid}`);
info(`Attachment content length: ${content.length}`);
let tag = await uploadFile(upid, content).catch(() => {
error(`Network error while uploading attachment ${upid}`);
});
if(!tag){
/**
* При ошибке загрузки по сети
*/
stopUpdateTimeInUpAttachment();
console.info("stop upd")
continue;
}
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
stopUpdateTimeInUpAttachment();
const preparedAttachment : Attachment = {
...attachment,
transport: {
transport_server: transportServer || "",
transport_tag: tag
},
preview: attachment.preview,
blob: ""
};
await updateTransportAfterUploading(dialog, message_id, preparedAttachment);
prepared.push(preparedAttachment);
}
return prepared;
}catch(e){
return prepared;
}
}
const stopUpdateTimeInUpAttachment = () => {
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
}
}
return {
prepareAttachmentsToSend
}
}

View File

@@ -13,6 +13,9 @@ import { Button, Flex, Text } from "@mantine/core";
import { useSound } from "@/app/hooks/useSound";
import useWindow from "@/app/hooks/useWindow";
import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE";
import { useDeattachedSender } from "../DialogProvider/useDeattachedSender";
import { AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
import { generateRandomKey } from "@/app/utils/utils";
export interface CallContextValue {
call: (callable: string) => void;
@@ -74,13 +77,20 @@ export function CallProvider(props : CallProviderProps) {
const roomIdRef = useRef<string>("");
const roleRef = useRef<CallRole | null>(null);
//const [sharedSecret, setSharedSecret] = useState<string>("");
const sharedSecretRef = useRef<string>("");
const sharedSecretRef = useRef<string>("");
const iceServersRef = useRef<RTCIceServer[]>([]);
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]);
const mutedRef = useRef<boolean>(false);
const soundRef = useRef<boolean>(true);
const {sendMessage} = useDeattachedSender();
const hasRemoteTrackRef = useRef<boolean>(false);
/**
* Используются для входа в звонок
*/
const callSessionIdRef = useRef<string>("");
const callTokenRef = useRef<string>("");
const {playSound, stopSound, stopLoopSound} = useSound();
const {setWindowPriority} = useWindow();
@@ -166,7 +176,6 @@ export function CallProvider(props : CallProviderProps) {
* Другая сторона отправила нам ICE кандидата для установления WebRTC соединения
*/
const candidate = JSON.parse(packet.getSdpOrCandidate());
console.info(candidate);
if(peerConnectionRef.current?.remoteDescription == null){
/**
* Удаленное описание еще не установлено, буферизуем кандидата, чтобы добавить его после установки удаленного описания
@@ -207,15 +216,13 @@ export function CallProvider(props : CallProviderProps) {
openCallsModal("The connection with the user was lost. The call has ended.")
end();
}
if(activeCall){
if(signalType == SignalType.RINGING_TIMEOUT) {
/**
* У нас уже есть активный звонок, игнорируем все сигналы, кроме сигналов от текущего звонка
* Другой стороне был отправлен сигнал звонка, но она не ответила на него в течении определенного времени
*/
if(packet.getSrc() != activeCall && packet.getSrc() != publicKey){
console.info("Received signal from " + packet.getSrc() + " but active call is with " + activeCall + ", ignoring");
info("Received signal for another call, ignoring");
return;
}
openCallsModal("The user did not answer the call in time. Please try again later.");
end();
return;
}
if(signalType == SignalType.END_CALL){
/**
@@ -228,73 +235,81 @@ export function CallProvider(props : CallProviderProps) {
/**
* Нам поступает звонок
*/
if(callState != CallState.ENDED){
/**
* У нас уже есть активный звонок, отправляем сигнал другой стороне, что линия занята
*/
return;
}
callSessionIdRef.current = packet.getCallId();
callTokenRef.current = packet.getJoinToken();
setWindowPriority(true);
playSound("ringtone.mp3", true);
setActiveCall(packet.getSrc());
setCallState(CallState.INCOMING);
setShowCallView(true);
}
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLER){
console.info("EXCHANGE SIGNAL RECEIVED, CALLER ROLE");
/**
* Другая сторона сгенерировала ключи для сессии и отправила нам публичную часть,
* теперь мы можем создать общую секретную сессию для шифрования звонка
*/
const sharedPublic = packet.getSharedPublic();
if(!sharedPublic){
info("Received key exchange signal without shared public key");
return;
}
const sessionKeys = generateSessionKeys();
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
info("Generated shared secret for call session: " + sharedSecretRef.current);
/**
* Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию
*/
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(packet.getSrc());
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
send(signalPacket);
setCallState(CallState.WEB_RTC_EXCHANGE);
/**
* Создаем комнату на сервере SFU, комнату создает звонящий
*/
let webRtcSignal = new PacketSignalPeer();
webRtcSignal.setSignalType(SignalType.CREATE_ROOM);
webRtcSignal.setSrc(publicKey);
webRtcSignal.setDst(packet.getSrc());
send(webRtcSignal);
}
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLEE){
if(signalType == SignalType.KEY_EXCHANGE){
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
/**
* Мы отправили свою публичную часть ключа другой стороне,
* теперь мы получили ее публичную часть и можем создать общую
* секретную сессию для шифрования звонка
* Другая сторона отправила нам ключи, теперь отправляем ей свои для генерации общего секрета
*/
const sharedPublic = packet.getSharedPublic();
if(!sharedPublic){
info("Received key exchange signal without shared public key");
return;
}
}
if(!sessionKeys){
info("Received key exchange signal but session keys are not generated");
return;
}
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
info("Generated shared secret for call session: " + sharedSecretRef.current);
setCallState(CallState.WEB_RTC_EXCHANGE);
if(roleRef.current == CallRole.CALLER){
/**
* Вызывающий уже отправил ключ, сессия сгенерирована, сообщаем серверу что звонок активен
*/
const activeSignal = new PacketSignalPeer();
activeSignal.setSrc(publicKey);
activeSignal.setDst(activeCall);
activeSignal.setSignalType(SignalType.ACTIVE);
send(activeSignal);
return;
}
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(activeCall);
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
send(signalPacket);
}
if(signalType == SignalType.CREATE_ROOM) {
if(signalType == SignalType.ACCEPT){
/**
* Создана комната для обмена WebRTC потоками
* Другая сторона приняла наш звонок, комната на SFU создалась, нужно сгенерировать ключи
*/
roomIdRef.current = packet.getRoomId();
info("WebRTC room created with id: " + packet.getRoomId());
const keys = generateSessionKeys();
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(activeCall);
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex'));
send(signalPacket);
}
if(signalType == SignalType.ACTIVE) {
if(!sessionKeys){
/**
* Сервер может отправить CREATE_ROOM сигнал, даже если мы приняли звонок на другом устройстве, по этому проверяем,
* на этом ли устройстве звонок принят посредством проверки наличия сгенерированных ключей шифрования
*/
stopLoopSound();
stopSound();
end();
return;
}
/**
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
*/
@@ -320,7 +335,11 @@ export function CallProvider(props : CallProviderProps) {
peerConnectionRef.current.onconnectionstatechange = () => {
console.info("Peer connection state changed: " + peerConnectionRef.current?.connectionState);
if(peerConnectionRef.current?.connectionState == "connected"){
setCallState(CallState.ACTIVE);
/**
* WebRTC соединение установлено, звонок активен, останавливаем все остальные звуки
* системы
*/
tryActivateCall();
info("WebRTC connection established, call is active");
}
}
@@ -335,7 +354,8 @@ export function CallProvider(props : CallProviderProps) {
* При получении медиа-трека с другой стороны
*/
if(remoteAudioRef.current && event.streams[0]){
console.info(event.streams);
hasRemoteTrackRef.current = true;
tryActivateCall();
remoteAudioRef.current.srcObject = event.streams[0];
remoteAudioRef.current.muted = !soundRef.current;
void remoteAudioRef.current.play().catch((e) => {
@@ -370,7 +390,16 @@ export function CallProvider(props : CallProviderProps) {
send(offerSignal);
return;
}
}, [activeCall, sessionKeys]);
}, [activeCall, sessionKeys, duration]);
const tryActivateCall = () => {
if(hasRemoteTrackRef.current && peerConnectionRef.current?.connectionState == "connected"){
stopLoopSound();
stopSound();
setCallState(CallState.ACTIVE);
info("Call is now active");
}
}
const openCallsModal = (text : string) => {
modals.open({
@@ -381,7 +410,9 @@ export function CallProvider(props : CallProviderProps) {
{text}
</Text>
<Flex align={'center'} justify={'flex-end'}>
<Button color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
<Button style={{
outline: 'none'
}} color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
Close
</Button>
</Flex>
@@ -423,22 +454,28 @@ export function CallProvider(props : CallProviderProps) {
const packetSignal = new PacketSignalPeer();
packetSignal.setSrc(publicKey);
packetSignal.setDst(activeCall);
packetSignal.setCallId(callSessionIdRef.current);
packetSignal.setJoinToken(callTokenRef.current);
packetSignal.setSignalType(SignalType.END_CALL);
send(packetSignal);
end();
}
const end = () => {
stopLoopSound();
stopSound();
if(callState == CallState.ACTIVE){
/**
* Только если звонок был активен воспроизводим звуки
*/
playSound("end_call.mp3");
}
if (remoteAudioRef.current) {
remoteAudioRef.current.pause();
remoteAudioRef.current.srcObject = null;
}
generateCallAttachment();
setDuration(0);
durationIntervalRef.current && clearInterval(durationIntervalRef.current);
setWindowPriority(false);
playSound("end_call.mp3");
peerConnectionRef.current?.close();
peerConnectionRef.current = null;
roomIdRef.current = "";
@@ -451,9 +488,33 @@ export function CallProvider(props : CallProviderProps) {
setDuration(0);
setMutedState(false);
setSoundState(true);
stopLoopSound();
stopSound();
roleRef.current = null;
}
/**
* Отправляет сообщение в диалог с звонящим с информацией о звонке
*/
const generateCallAttachment = () => {
if(roleRef.current != CallRole.CALLER){
/**
* Только звонящий отправляет информацию о звонке в виде вложения, чтобы ее можно было отобразить в UI диалога, например длительность звонка
*/
return;
}
sendMessage(activeCall, "", [{
id: generateRandomKey(16),
preview: duration.toString(),
type: AttachmentType.CALL,
transport: {
transport_server: "",
transport_tag: ""
},
blob: ""
}], true);
}
const accept = () => {
if(callState != CallState.INCOMING){
/**
@@ -465,15 +526,22 @@ export function CallProvider(props : CallProviderProps) {
stopLoopSound();
stopSound();
/**
* Звонок принят, генерируем ключи для сессии и отправляем их другой стороне для установления защищенного канала связи
* Звонок принят, генерируем свой ключ для будующего обмена
*/
generateSessionKeys();
/**
* Отправляем сигнал что звонок принят другой стороне, чтобы она могла начать обмен ключами и установку соединения
*/
const keys = generateSessionKeys();
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(activeCall);
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex'));
signalPacket.setCallId(callSessionIdRef.current);
signalPacket.setJoinToken(callTokenRef.current);
signalPacket.setSignalType(SignalType.ACCEPT);
send(signalPacket);
/**
* Устанавливаем состояние звонка и стадию обмена ключами
*/
setCallState(CallState.KEY_EXCHANGE);
roleRef.current = CallRole.CALLEE;
}

View File

@@ -1,105 +1,114 @@
function toArrayBuffer(src: Buffer | Uint8Array): ArrayBuffer {
const u8 = src instanceof Uint8Array ? src : new Uint8Array(src);
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
}
import _sodium from "libsodium-wrappers-sumo";
type KeyInput = Buffer | Uint8Array;
const senderAttached = new WeakSet<RTCRtpSender>();
const receiverAttached = new WeakSet<RTCRtpReceiver>();
async function importAesCtrKey(input: KeyInput): Promise<CryptoKey> {
const keyBytes = toArrayBuffer(input);
if (keyBytes.byteLength !== 32) {
throw new Error(`E2EE key must be 32 bytes, got ${keyBytes.byteLength}`);
}
let sodiumReady = false;
let sodium: typeof _sodium;
return crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "AES-CTR" },
false,
["encrypt", "decrypt"]
);
export async function initE2EE(): Promise<void> {
if (sodiumReady) return;
await _sodium.ready;
sodium = _sodium;
sodiumReady = true;
}
function toBigIntTs(ts: unknown): bigint {
if (typeof ts === "bigint") return ts;
if (typeof ts === "number") return BigInt(ts);
return 0n;
function toUint8Array(input: KeyInput): Uint8Array {
const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
return new Uint8Array(u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength));
}
/**
* 16-byte counter:
* [0..3] direction marker
* [4..11] frame timestamp
* [12..15] reserved
*/
function buildCounter(direction: number, timestamp: unknown): ArrayBuffer {
const iv = new Uint8Array(16);
const dv = new DataView(iv.buffer);
dv.setUint32(0, direction >>> 0, false);
dv.setBigUint64(4, toBigIntTs(timestamp), false);
dv.setUint32(12, 0, false);
return toArrayBuffer(iv);
function fillNonceFromTimestamp(nonce: Uint8Array, tsRaw: unknown): void {
nonce.fill(0);
let ts = 0n;
if (typeof tsRaw === "bigint") ts = tsRaw;
else if (typeof tsRaw === "number" && Number.isFinite(tsRaw)) ts = BigInt(Math.floor(tsRaw));
nonce[0] = Number((ts >> 56n) & 0xffn);
nonce[1] = Number((ts >> 48n) & 0xffn);
nonce[2] = Number((ts >> 40n) & 0xffn);
nonce[3] = Number((ts >> 32n) & 0xffn);
nonce[4] = Number((ts >> 24n) & 0xffn);
nonce[5] = Number((ts >> 16n) & 0xffn);
nonce[6] = Number((ts >> 8n) & 0xffn);
nonce[7] = Number(ts & 0xffn);
}
function createFrameProcessor(key: Uint8Array) {
const nonceLen = sodium.crypto_stream_xchacha20_NONCEBYTES; // 24
const nonce = new Uint8Array(nonceLen);
return function processFrame(data: ArrayBuffer, timestamp: unknown): ArrayBuffer {
const input = new Uint8Array(data);
fillNonceFromTimestamp(nonce, timestamp);
const output = sodium.crypto_stream_xchacha20_xor(input, nonce, key);
return output.buffer.slice(output.byteOffset, output.byteOffset + output.byteLength) as ArrayBuffer;
};
}
function createTransform(processFrame: (data: ArrayBuffer, timestamp: unknown) => ArrayBuffer) {
return new TransformStream<any, any>({
transform(frame, controller) {
try {
frame.data = processFrame(frame.data, frame.timestamp);
} catch (e) {
console.error("[E2EE] frame error:", e);
}
controller.enqueue(frame);
}
});
}
export async function attachSenderE2EE(sender: RTCRtpSender, keyInput: KeyInput): Promise<void> {
const key = await importAesCtrKey(keyInput);
if (senderAttached.has(sender)) return;
senderAttached.add(sender);
const anySender = sender as unknown as {
createEncodedStreams?: () => { readable: ReadableStream<any>; writable: WritableStream<any> };
};
await initE2EE();
const key = toUint8Array(keyInput);
const keyLen = sodium.crypto_stream_xchacha20_KEYBYTES; // 32
if (key.byteLength < keyLen) {
throw new Error(`Key must be at least ${keyLen} bytes`);
}
const anySender = sender as any;
if (!anySender.createEncodedStreams) {
throw new Error("createEncodedStreams is not available on RTCRtpSender");
throw new Error("createEncodedStreams not available on RTCRtpSender");
}
const { readable, writable } = anySender.createEncodedStreams();
const processFrame = createFrameProcessor(key.slice(0, keyLen));
const enc = new TransformStream<any, any>({
async transform(frame, controller) {
const counter = buildCounter(1, frame.timestamp);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-CTR", counter, length: 64 },
key,
frame.data
);
frame.data = encrypted; // same length
controller.enqueue(frame);
}
});
readable.pipeThrough(enc).pipeTo(writable).catch((e) => {
console.error("Sender E2EE pipeline failed:", e);
});
readable
.pipeThrough(createTransform(processFrame))
.pipeTo(writable)
.catch((e) => console.error("[E2EE] Sender pipeline failed:", e));
}
export async function attachReceiverE2EE(receiver: RTCRtpReceiver, keyInput: KeyInput): Promise<void> {
const key = await importAesCtrKey(keyInput);
if (receiverAttached.has(receiver)) return;
receiverAttached.add(receiver);
const anyReceiver = receiver as unknown as {
createEncodedStreams?: () => { readable: ReadableStream<any>; writable: WritableStream<any> };
};
await initE2EE();
const key = toUint8Array(keyInput);
const keyLen = sodium.crypto_stream_xchacha20_KEYBYTES; // 32
if (key.byteLength < keyLen) {
throw new Error(`Key must be at least ${keyLen} bytes`);
}
const anyReceiver = receiver as any;
if (!anyReceiver.createEncodedStreams) {
throw new Error("createEncodedStreams is not available on RTCRtpReceiver");
throw new Error("createEncodedStreams not available on RTCRtpReceiver");
}
const { readable, writable } = anyReceiver.createEncodedStreams();
const processFrame = createFrameProcessor(key.slice(0, keyLen));
const dec = new TransformStream<any, any>({
async transform(frame, controller) {
const counter = buildCounter(1, frame.timestamp);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-CTR", counter, length: 64 },
key,
frame.data
);
frame.data = decrypted; // same length
controller.enqueue(frame);
}
});
readable.pipeThrough(dec).pipeTo(writable).catch((e) => {
console.error("Receiver E2EE pipeline failed:", e);
});
readable
.pipeThrough(createTransform(processFrame))
.pipeTo(writable)
.catch((e) => console.error("[E2EE] Receiver pipeline failed:", e));
}

View File

@@ -1,7 +1,7 @@
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from '@/app/workers/crypto/crypto';
import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto';
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
import { createContext, useEffect, useRef, useState } from 'react';
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
import { Attachment, AttachmentTransport, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
import { usePrivatePlain } from '../AccountProvider/usePrivatePlain';
import { usePublicKey } from '../AccountProvider/usePublicKey';
import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read';
@@ -11,21 +11,18 @@ import { useBlacklist } from '../BlacklistProvider/useBlacklist';
import { useLogger } from '@/app/hooks/useLogger';
import { useSender } from '../ProtocolProvider/useSender';
import { usePacket } from '../ProtocolProvider/usePacket';
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, MESSAGE_MAX_TIME_TO_DELEVERED_S, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
import { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery';
import { useIdle } from '@mantine/hooks';
import { useWindowFocus } from '@/app/hooks/useWindowFocus';
import { useDialogsCache } from './useDialogsCache';
import { useConsoleLogger } from '@/app/hooks/useConsoleLogger';
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
import { MessageReply } from './useReplyMessages';
import { useTransport } from '../TransportProvider/useTransport';
import { useFileStorage } from '@/app/hooks/useFileStorage';
import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
import { useDialogsList } from '../DialogListProvider/useDialogsList';
import { useGroups } from './useGroups';
import { useMentions } from '../DialogStateProvider.tsx/useMentions';
import { base64ImageToBlurhash } from '@/app/workers/image/image';
export interface DialogContextValue {
loading: boolean;
@@ -33,7 +30,6 @@ export interface DialogContextValue {
setMessages: (messages: React.SetStateAction<Message[]>) => void;
dialog: string;
clearDialogCache: () => void;
prepareAttachmentsToSend: (password: string, attachments: Attachment[]) => Promise<Attachment[]>;
loadMessagesToTop: () => Promise<void>;
loadMessagesToMessageId: (messageId: string) => Promise<void>;
}
@@ -50,6 +46,7 @@ export interface AttachmentMeta {
id: string;
type: AttachmentType;
preview: string;
transport: AttachmentTransport;
}
export interface Message {
@@ -71,6 +68,23 @@ interface DialogProviderProps {
dialog: string;
}
type DialogMessageEvent = {
dialogId: string;
message: Message;
};
const bus = new EventTarget();
export const emitDialogMessage = (payload: DialogMessageEvent) => {
bus.dispatchEvent(new CustomEvent<DialogMessageEvent>("dialog:message", { detail: payload }));
};
export const onDialogMessage = (handler: (payload: DialogMessageEvent) => void) => {
const listener = (e: Event) => handler((e as CustomEvent<DialogMessageEvent>).detail);
bus.addEventListener("dialog:message", listener);
return () => bus.removeEventListener("dialog:message", listener);
};
export function DialogProvider(props: DialogProviderProps) {
const [messages, setMessages] = useState<Message[]>([]);
const {allQuery, runQuery} = useDatabase();
@@ -88,15 +102,21 @@ export function DialogProvider(props: DialogProviderProps) {
const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache();
const {info, warn, error} = useConsoleLogger('DialogProvider');
const [viewState] = useViewPanelsState();
const {uploadFile} = useTransport();
const {readFile} = useFileStorage();
const intervalsRef = useRef<NodeJS.Timeout>(null);
const systemAccounts = useSystemAccounts();
const {updateDialog} = useDialogsList();
const {hasGroup, getGroupKey} = useGroups();
const {popMention, isMentioned} = useMentions();
useEffect(() => {
const unsub = onDialogMessage(({ dialogId, message }) => {
if (dialogId !== props.dialog) return;
setMessages((prev) => [...prev, message]);
});
return unsub;
}, [props.dialog]);
useEffect(() => {
setCurrentDialogPublicKeyView(props.dialog);
return () => {
@@ -195,26 +215,40 @@ export function DialogProvider(props: DialogProviderProps) {
readUpdated = true;
}
let decryptKey = '';
if(message.from_me && message.chacha_key != "" && !message.chacha_key.startsWith("sync:")){
/**
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key
*/
try{
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key), 'binary').toString('hex');
}catch(e) {
decryptKey = "";
}
}
if(message.from_me && message.chacha_key != "" && message.chacha_key.startsWith("sync:")){
/**
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это
* сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет,
* значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (plain_message)
*/
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('utf-8');
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('hex');
}
if(hasGroup(props.dialog)){
/**
* Если это групповое сообщение, то получаем ключ группы
*/
decryptKey = await getGroupKey(props.dialog);
/**
* Приводим к HEX так как этого требует формат расшифровки вложений в приложении
*/
decryptKey = Buffer.from(decryptKey).toString('hex');
}
if(!message.from_me && !hasGroup(props.dialog)){
/**
* Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом
*/
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
}
finalMessages.push({
from_public_key: message.from_public_key,
@@ -450,10 +484,8 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
attachments.push({
id: attachment.id,
preview: attachment.preview,
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
...attachment,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
});
}
@@ -463,7 +495,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content,
timestamp: timestamp,
readed: 0, //сообщение прочитано
chacha_key: chachaDecryptedKey.toString('utf-8'),
chacha_key: chachaDecryptedKey.toString('hex'),
from_me: 1, //сообщение от нас
plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED,
@@ -530,9 +562,7 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
attachments.push({
id: attachment.id,
preview: attachment.preview,
type: attachment.type,
...attachment,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
});
}
@@ -543,7 +573,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content,
timestamp: timestamp,
readed: 0,
chacha_key: groupKey,
chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: 1,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,
@@ -608,20 +638,18 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
attachments.push({
id: attachment.id,
preview: attachment.preview,
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
...attachment,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
});
}
console.info(attachments);
const newMessage : Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: chachaDecryptedKey.toString('utf-8'),
chacha_key: chachaDecryptedKey.toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED,
@@ -688,9 +716,7 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
attachments.push({
id: attachment.id,
preview: attachment.preview,
type: attachment.type,
...attachment,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
});
}
@@ -701,7 +727,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: groupKey,
chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,
@@ -775,7 +801,7 @@ export function DialogProvider(props: DialogProviderProps) {
* Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом
*/
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
}
finalMessages.push({
from_public_key: message.from_public_key,
@@ -860,7 +886,7 @@ export function DialogProvider(props: DialogProviderProps) {
* Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом
*/
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
}
finalMessages.push({
from_public_key: message.from_public_key,
@@ -919,6 +945,16 @@ export function DialogProvider(props: DialogProviderProps) {
});
continue;
}
if(meta.type == AttachmentType.CALL){
/**
* Если это звонок
*/
attachments.push({
...meta,
blob: ""
});
continue;
}
const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`);
if(!fileData) {
attachments.push({
@@ -935,115 +971,18 @@ export function DialogProvider(props: DialogProviderProps) {
id: meta.id,
blob: blob,
type: meta.type,
preview: meta.preview
preview: meta.preview,
transport: meta.transport
});
}
return attachments;
}catch(e) {
console.info(e);
error("Failed to parse attachments");
}
return [];
}
/**
* Обновляет временную метку в сообщении, пока вложения отправляются,
* потому что если этого не делать, то сообщение может быть помечено как
* не доставленное из-за таймаута доставки
* @param attachments Вложения
*/
const doTimestampUpdateImMessageWhileAttachmentsSend = (attachments : Attachment[]) => {
if(intervalsRef.current){
clearInterval(intervalsRef.current);
}
intervalsRef.current = setInterval(() => {
//update timestamp in message to keep message marked as error
updateDialog(props.dialog);
setMessages((prev) => {
return prev.map((value) => {
if(value.attachments.length <= 0){
return value;
}
if(value.attachments[0].id != attachments[0].id){
return value;
}
runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), value.message_id]);
return {
...value,
timestamp: Date.now()
};
})
});
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
}
/**
* Удаляет старый тег если вложения были подготовлены заново
* например при пересылке сообщений
*/
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
if(preview.indexOf("::") == -1){
return preview;
}
let parts = preview.split("::");
return parts.slice(1).join("::");
}
/**
* Подготавливает вложения для отправки. Подготовка
* состоит в загрузке файлов на транспортный сервер, мы не делаем
* это через WebSocket из-за ограничений по размеру сообщений,
* а так же из-за надежности доставки файлов через HTTP
* @param attachments Attachments to prepare for sending
*/
const prepareAttachmentsToSend = async (password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
if(attachments.length <= 0){
return [];
}
let prepared : Attachment[] = [];
try{
for(let i = 0; i < attachments.length; i++){
const attachment : Attachment = attachments[i];
if(attachment.type == AttachmentType.MESSAGES){
let reply : MessageReply[] = JSON.parse(attachment.blob)
for(let j = 0; j < reply.length; j++){
reply[j].attachments = await prepareAttachmentsToSend(password, reply[j].attachments, true);
}
prepared.push({
...attachment,
blob: await encodeWithPassword(password, JSON.stringify(reply))
});
continue;
}
if((attachment.type == AttachmentType.IMAGE
|| attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
/**
* Загружаем превью blurhash для изображения
*/
const blurhash = await base64ImageToBlurhash(attachment.blob);
attachment.preview = blurhash;
}
doTimestampUpdateImMessageWhileAttachmentsSend(attachments);
const content = await encodeWithPassword(password, attachment.blob);
const upid = attachment.id;
info(`Uploading attachment with upid: ${upid}`);
info(`Attachment content length: ${content.length}`);
let tag = await uploadFile(upid, content);
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
}
prepared.push({
...attachment,
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
blob: ""
});
}
return prepared;
}catch(e){
return prepared;
}
}
/**
* Дедубликация сообщений по message_id, так как может возникать ситуация, что одно и то же сообщение
* может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации
@@ -1071,7 +1010,6 @@ export function DialogProvider(props: DialogProviderProps) {
setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog));
},
dialog: props.dialog,
prepareAttachmentsToSend,
loadMessagesToTop,
loadMessagesToMessageId
}}>

View File

@@ -0,0 +1,161 @@
import { generateRandomKey } from "@/app/utils/utils";
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
import { useGroups } from "./useGroups";
import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5 } from "@/app/workers/crypto/crypto";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { AttachmentMeta, DeliveredMessageState, emitDialogMessage, Message } from "./DialogProvider";
import { useDatabase } from "../DatabaseProvider/useDatabase";
import { useFileStorage } from "@/app/hooks/useFileStorage";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
import { useSender } from "../ProtocolProvider/useSender";
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
/**
* Используется для отправки сообщений не внутри DialogProvider, а например в CallProvider,
* когда нам нужно отправить сообщение от своего имени что мы совершли звонок (Attachment.CALL)
*/
export function useDeattachedSender() {
const {hasGroup, getGroupKey} = useGroups();
const privatePlain = usePrivatePlain();
const {warn} = useConsoleLogger('useDeattachedSender');
const {runQuery} = useDatabase();
const {writeFile} = useFileStorage();
const publicKey = usePublicKey();
const {updateDialog} = useDialogsList();
const [protocolState] = useProtocolState();
const privateKey = usePrivateKeyHash();
const send = useSender();
const {prepareAttachmentsToSend} = usePrepareAttachment();
/**
* Отправка сообщения в диалог
* @param dialog ID диалога, может быть как публичным ключом собеседника, так и ID группового диалога
* @param message Сообщение
* @param attachemnts Вложения
*/
const sendMessage = async (dialog: string, message: string, attachemnts : Attachment[], serverSent: boolean = false) => {
const messageId = generateRandomKey(16);
let cahchaEncrypted = {ciphertext: "", key: "", nonce: ""} as any;
let key = Buffer.from("");
let encryptedKey = "";
let plainMessage = "";
let content = "";
if(!hasGroup(dialog)){
cahchaEncrypted = (await chacha20Encrypt(message.trim()) as any);
key = Buffer.concat([
Buffer.from(cahchaEncrypted.key, "hex"),
Buffer.from(cahchaEncrypted.nonce, "hex")]);
encryptedKey = await encrypt(key.toString('binary'), dialog);
plainMessage = await encodeWithPassword(privatePlain, message.trim());
content = cahchaEncrypted.ciphertext;
}else{
/**
* Это группа, там шифрование устроено иначе
* для групп используется один общий ключ, который
* есть только у участников группы, сам ключ при этом никак
* не отправляется по сети (ведь ID у группы общий и у каждого
* и так есть этот ключ)
*/
const groupKey = await getGroupKey(dialog);
if(!groupKey){
warn("Group key not found for dialog " + dialog);
return;
}
content = await encodeWithPassword(groupKey, message.trim());
plainMessage = await encodeWithPassword(privatePlain, message.trim());
encryptedKey = ""; // В группах не нужен зашифрованный ключ
key = Buffer.from(groupKey);
}
/**
* Нужно зашифровать ключ еще и нашим ключом,
* чтобы в последствии мы могли расшифровать этот ключ у своих
* же сообщений (смотреть problem_sync.md)
*/
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
emitDialogMessage({
dialogId: dialog,
message: {
from_public_key: publicKey,
to_public_key: dialog,
content: content,
timestamp: Date.now(),
readed: publicKey == dialog ? 1 : 0,
chacha_key: "",
from_me: 1,
plain_message: message,
delivered: serverSent ? (publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: attachemnts
} as Message
})
let attachmentsMeta : AttachmentMeta[] = [];
for(let i = 0; i < attachemnts.length; i++) {
const attachment = attachemnts[i];
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview,
transport: attachment.transport
});
if(attachment.type == AttachmentType.FILE){
/**
* Обычно вложения дублируются на диск. Так происходит со всем.
* Кроме файлов. Если дублировать файл весом в 2гб на диск отправка будет
* занимать очень много времени.
* К тому же, это приведет к созданию ненужной копии у отправителя
*/
continue;
}
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`, Buffer.from(await encodeWithPassword(privatePlain, attachment.blob)).toString('binary'));
}
await runQuery(`
INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
(serverSent ? (protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED)
), JSON.stringify(attachmentsMeta)]);
updateDialog(dialog);
if(publicKey == ""
|| dialog == ""
|| publicKey == dialog) {
return;
}
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog);
return;
}
if(!serverSent){
return;
}
const packet = new PacketMessage();
packet.setFromPublicKey(publicKey);
packet.setToPublicKey(dialog);
packet.setContent(content);
packet.setChachaKey(encryptedKey);
packet.setPrivateKey(privateKey);
packet.setMessageId(messageId);
packet.setTimestamp(Date.now());
packet.setAttachments(preparedToNetworkSendAttachements);
packet.setAesChachaKey(aesChachaKey);
send(packet);
}
return {sendMessage};
}

View File

@@ -14,6 +14,7 @@ import { useProtocolState } from "../ProtocolProvider/useProtocolState";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useGroups } from "./useGroups";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
export function useDialog() : {
messages: Message[];
@@ -34,8 +35,7 @@ export function useDialog() : {
throw new Error("useDialog must be used within a DialogProvider");
}
const {loading,
messages,
prepareAttachmentsToSend,
messages,
clearDialogCache,
setMessages,
dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
@@ -47,6 +47,7 @@ export function useDialog() : {
const [protocolState] = useProtocolState();
const {hasGroup, getGroupKey} = useGroups();
const {warn} = useConsoleLogger('useDialog');
const {prepareAttachmentsToSend} = usePrepareAttachment();
/**
* Отправка сообщения в диалог
@@ -95,14 +96,13 @@ export function useDialog() : {
* же сообщений (смотреть problem_sync.md)
*/
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
setMessages((prev : Message[]) => ([...prev, {
from_public_key: publicKey,
to_public_key: dialog,
content: content,
timestamp: Date.now(),
readed: publicKey == dialog ? 1 : 0,
chacha_key: "",
chacha_key: key.toString('hex'),
from_me: 1,
plain_message: message,
delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
@@ -117,7 +117,8 @@ export function useDialog() : {
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
preview: attachment.preview,
transport: attachment.transport
});
if(attachment.type == AttachmentType.FILE){
/**
@@ -134,7 +135,7 @@ export function useDialog() : {
await runQuery(`
INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, aesChachaKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING
), JSON.stringify(attachmentsMeta)]);
updateDialog(dialog);
@@ -144,10 +145,13 @@ export function useDialog() : {
return;
}
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
console.info("Sending key for message ", key.toString('hex'));
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(key.toString('utf-8'), attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){
console.info(attachemnts);
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
if(preparedToNetworkSendAttachements.length < attachemnts.length){
/**
* Если не удалось нормально загрузить все вложения - тогда не отправляем сообщение
*/
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog);
return;

View File

@@ -13,7 +13,7 @@ import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
import { DeliveredMessageState, Message } from "./DialogProvider";
import { AttachmentMeta, DeliveredMessageState, Message } from "./DialogProvider";
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
@@ -104,7 +104,7 @@ export function useDialogFiber() {
decryptedContent = '';
}
let attachmentsMeta: any[] = [];
let attachmentsMeta: AttachmentMeta[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
@@ -129,7 +129,8 @@ export function useDialogFiber() {
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
preview: attachment.preview,
transport: attachment.transport
});
}
@@ -139,7 +140,7 @@ export function useDialogFiber() {
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: groupKey,
chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,
@@ -261,7 +262,7 @@ export function useDialogFiber() {
const nonce = chachaDecryptedKey.slice(32);
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
let attachmentsMeta: any[] = [];
let attachmentsMeta: AttachmentMeta[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
@@ -277,7 +278,7 @@ export function useDialogFiber() {
* Этот тип вложения приходит сразу в blob и не нуждается
* в последующем скачивании
*/
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob);
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
messageAttachments[nextLength - 1].blob = decryptedBlob;
@@ -286,7 +287,8 @@ export function useDialogFiber() {
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
preview: attachment.preview,
transport: attachment.transport
});
}
@@ -296,7 +298,7 @@ export function useDialogFiber() {
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: chachaDecryptedKey.toString('utf-8'),
chacha_key: chachaDecryptedKey.toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED,

View File

@@ -30,7 +30,7 @@ export function useDialogsCache() {
const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => {
/**
* TODO: Optimize this function to avoid full map if possible
* TODO: Оптимизировать чтобы проходил снизу вверх
*/
let newCache = dialogsCache.map((cache) => {
let newMessages = cache.messages.map((message) => {

View File

@@ -19,6 +19,7 @@ export interface MessageReply {
message: string;
attachments: Attachment[];
message_id: string;
chacha_key_plain: string;
}
export function useReplyMessages() {
@@ -53,7 +54,6 @@ export function useReplyMessages() {
}
replyMessages.messages.push(message);
const sortedByTime = replyMessages.messages.sort((a, b) => a.timestamp - b.timestamp);
setReplyMessages({
publicKey: dialog,
messages: sortedByTime

View File

@@ -20,7 +20,7 @@ import { useGroupInviteStatus } from "./useGroupInviteStatus";
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
import { useUpdateSyncTime } from "./useUpdateSyncTime";
import { useFileStorage } from "@/app/hooks/useFileStorage";
import { DeliveredMessageState, Message } from "./DialogProvider";
import { AttachmentMeta, DeliveredMessageState, Message } from "./DialogProvider";
import { MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from "@/app/constants";
import { useMemory } from "../MemoryProvider/useMemory";
import { useDialogsCache } from "./useDialogsCache";
@@ -165,7 +165,7 @@ export function useSynchronize() {
const nonce = chachaDecryptedKey.slice(32);
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
await updateSyncTime(timestamp);
let attachmentsMeta: any[] = [];
let attachmentsMeta: AttachmentMeta[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
@@ -181,7 +181,7 @@ export function useSynchronize() {
* Этот тип вложения приходит сразу в blob и не нуждается
* в последующем скачивании
*/
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob);
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
messageAttachments[nextLength - 1].blob = decryptedBlob;
@@ -190,7 +190,8 @@ export function useSynchronize() {
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
preview: attachment.preview,
transport: attachment.transport
});
}
@@ -200,7 +201,7 @@ export function useSynchronize() {
content: content,
timestamp: timestamp,
readed: 1, //сообщение прочитано
chacha_key: chachaDecryptedKey.toString('utf-8'),
chacha_key: chachaDecryptedKey.toString('hex'),
from_me: 1, //сообщение от нас
plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED,
@@ -347,7 +348,7 @@ export function useSynchronize() {
decryptedContent = '';
}
let attachmentsMeta: any[] = [];
let attachmentsMeta: AttachmentMeta[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
@@ -372,7 +373,8 @@ export function useSynchronize() {
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
preview: attachment.preview,
transport: attachment.transport
});
}

View File

@@ -0,0 +1,300 @@
import { createContext, useEffect, useRef, useState } from "react";
export interface PlayerContextValue {
playAudio: (
artist: string,
title: string,
audio: string | Blob | File,
messageId?: string | null
) => void;
playing: boolean;
pause: () => void;
resume: () => void;
stop: () => void;
setDuration: (duration: number) => void;
duration: number;
totalDuration: number;
currentMessageId: string | null;
lastMessageId: string | null;
lastError: string | null;
}
export const PlayerContext = createContext<PlayerContextValue | null>(null);
interface PlayerProviderProps {
children: React.ReactNode;
}
export function PlayerProvider(props: PlayerProviderProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const objectUrlRef = useRef<string | null>(null);
const rafTimeUpdateRef = useRef<number | null>(null);
const isLoadingRef = useRef(false);
const isSeekingRef = useRef(false);
const durationRef = useRef(0);
const totalDurationRef = useRef(0);
const isPlayingRef = useRef(false);
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDurationState] = useState(0);
const [totalDuration, setTotalDuration] = useState(0);
const [lastError, setLastError] = useState<string | null>(null);
const [currentMessageId, setCurrentMessageId] = useState<string | null>(null);
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
const commitPlaying = (next: boolean) => {
if (isPlayingRef.current === next) return;
isPlayingRef.current = next;
setIsPlaying(next);
};
const commitDuration = (next: number) => {
const safe = Number.isFinite(next) && next >= 0 ? next : 0;
if (Math.abs(safe - durationRef.current) < 0.033) return;
durationRef.current = safe;
setDurationState(safe);
};
const commitTotalDuration = (next: number) => {
const safe = Number.isFinite(next) && next > 0 ? next : 0;
if (Math.abs(safe - totalDurationRef.current) < 0.05) return;
totalDurationRef.current = safe;
setTotalDuration(safe);
};
const decodeMediaError = (err: MediaError | null) => {
if (!err) return "Unknown media error";
switch (err.code) {
case MediaError.MEDIA_ERR_ABORTED:
return "Playback aborted";
case MediaError.MEDIA_ERR_NETWORK:
return "Network error while loading audio";
case MediaError.MEDIA_ERR_DECODE:
return "Audio decode error";
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
return "Audio source is not supported";
default:
return `Unknown media error (${err.code})`;
}
};
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onPlay = () => {
if (isLoadingRef.current) return;
commitPlaying(true);
};
const onPause = () => {
if (isLoadingRef.current) return;
commitPlaying(false);
};
const onEnded = () => {
commitPlaying(false);
durationRef.current = 0;
setDurationState(0);
setCurrentMessageId(null);
};
const onTimeUpdate = () => {
if (isLoadingRef.current) return;
if (isSeekingRef.current) return;
if (rafTimeUpdateRef.current != null) return;
rafTimeUpdateRef.current = requestAnimationFrame(() => {
rafTimeUpdateRef.current = null;
if (!isLoadingRef.current && !isSeekingRef.current) {
commitDuration(audio.currentTime || 0);
}
});
};
const onLoadedMetadata = () => commitTotalDuration(audio.duration);
const onDurationChange = () => commitTotalDuration(audio.duration);
const onSeeked = () => {
if (isSeekingRef.current) {
isSeekingRef.current = false;
if (!isLoadingRef.current) commitDuration(audio.currentTime || 0);
return;
}
if (isLoadingRef.current) return;
commitDuration(audio.currentTime || 0);
};
const onCanPlay = () => {
if (isLoadingRef.current) isLoadingRef.current = false;
};
const onError = (_e: Event) => {
const message = decodeMediaError(audio.error);
setLastError(message);
console.error("Audio playback error", {
message,
mediaError: audio.error,
currentSrc: audio.currentSrc,
readyState: audio.readyState,
networkState: audio.networkState,
});
};
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
audio.addEventListener("ended", onEnded);
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("loadedmetadata", onLoadedMetadata);
audio.addEventListener("durationchange", onDurationChange);
audio.addEventListener("seeked", onSeeked);
audio.addEventListener("canplay", onCanPlay);
audio.addEventListener("error", onError);
return () => {
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
audio.removeEventListener("ended", onEnded);
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
audio.removeEventListener("durationchange", onDurationChange);
audio.removeEventListener("seeked", onSeeked);
audio.removeEventListener("canplay", onCanPlay);
audio.removeEventListener("error", onError);
if (rafTimeUpdateRef.current != null) {
cancelAnimationFrame(rafTimeUpdateRef.current);
rafTimeUpdateRef.current = null;
}
};
}, []);
useEffect(() => {
return () => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
};
}, []);
const playAudio = (
artist: string,
title: string,
audio: string | Blob | File,
messageId?: string | null
) => {
const el = audioRef.current;
if (!el) return;
// чтобы не было warning о неиспользуемых args при строгих правилах
void artist;
void title;
setLastError(null);
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
const audioSrc = typeof audio === "string" ? audio : URL.createObjectURL(audio);
if (typeof audio !== "string") {
objectUrlRef.current = audioSrc;
}
isLoadingRef.current = true;
isSeekingRef.current = false;
el.src = audioSrc;
durationRef.current = 0;
const msgId = messageId ?? null;
setCurrentMessageId(msgId);
if (msgId) setLastMessageId(msgId);
isPlayingRef.current = true;
setIsPlaying(true);
const prevDuration = durationRef.current;
requestAnimationFrame(() => {
if (durationRef.current === prevDuration) {
setDurationState(0);
}
});
void el.play().catch((err) => {
isLoadingRef.current = false;
commitPlaying(false);
setLastError(err instanceof Error ? err.message : "play() failed");
});
};
const pause = () => {
const el = audioRef.current;
if (!el) return;
el.pause();
};
const resume = () => {
const el = audioRef.current;
if (!el) return;
commitPlaying(true);
void el.play().catch((err) => {
commitPlaying(false);
setLastError(err instanceof Error ? err.message : "resume() failed");
});
};
const stop = () => {
const el = audioRef.current;
if (!el) return;
isLoadingRef.current = true;
el.pause();
el.currentTime = 0;
isLoadingRef.current = false;
durationRef.current = 0;
setDurationState(0);
commitPlaying(false);
setCurrentMessageId(null);
};
const setDuration = (sec: number) => {
const el = audioRef.current;
if (!el) return;
isSeekingRef.current = true;
el.currentTime = Math.max(0, sec);
commitDuration(el.currentTime || 0);
};
return (
<PlayerContext.Provider
value={{
playAudio,
playing: isPlaying,
pause,
resume,
stop,
setDuration,
duration,
totalDuration,
currentMessageId,
lastMessageId,
lastError,
}}
>
{props.children}
<audio ref={audioRef} />
</PlayerContext.Provider>
);
}

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { PlayerContext, PlayerContextValue } from "./PlayerProvider";
export function usePlayerContext() : PlayerContextValue {
const context = useContext(PlayerContext);
if (!context) {
throw new Error("useAudioPlayer must be used within a PlayerProvider");
}
return context;
}

View File

@@ -5,14 +5,26 @@ export enum AttachmentType {
IMAGE = 0,
MESSAGES = 1,
FILE = 2,
AVATAR = 3
AVATAR = 3,
CALL = 4,
VOICE = 5
}
/**
* Информация о транспортировке вложения, нужна для загрузки и скачивания вложений с транспортного сервера
*/
export interface AttachmentTransport {
transport_tag: string;
transport_server: string;
}
export interface Attachment {
id: string;
blob: string;
type: AttachmentType;
preview: string;
transport: AttachmentTransport;
}
export class PacketMessage extends Packet {
@@ -41,7 +53,7 @@ export class PacketMessage extends Packet {
this.toPublicKey = stream.readString();
this.content = stream.readString();
this.chachaKey = stream.readString();
this.timestamp = stream.readInt64();
this.timestamp = Number(stream.readInt64());
this.privateKey = stream.readString();
this.messageId = stream.readString();
let attachmentsCount = stream.readInt8();
@@ -50,7 +62,11 @@ export class PacketMessage extends Packet {
let preview = stream.readString();
let blob = stream.readString();
let type = stream.readInt8() as AttachmentType;
this.attachments.push({id, preview, type, blob});
const transport : AttachmentTransport = {
transport_tag: stream.readString(),
transport_server: stream.readString()
}
this.attachments.push({id, preview, type, blob, transport});
}
this.aesChachaKey = stream.readString();
}
@@ -62,7 +78,7 @@ export class PacketMessage extends Packet {
stream.writeString(this.toPublicKey);
stream.writeString(this.content);
stream.writeString(this.chachaKey);
stream.writeInt64(this.timestamp);
stream.writeInt64(BigInt(this.timestamp));
stream.writeString(this.privateKey);
stream.writeString(this.messageId);
stream.writeInt8(this.attachments.length);
@@ -71,6 +87,8 @@ export class PacketMessage extends Packet {
stream.writeString(this.attachments[i].preview);
stream.writeString(this.attachments[i].blob);
stream.writeInt8(this.attachments[i].type);
stream.writeString(this.attachments[i].transport.transport_tag);
stream.writeString(this.attachments[i].transport.transport_server);
}
stream.writeString(this.aesChachaKey);
return stream;

View File

@@ -6,9 +6,14 @@ export enum SignalType {
KEY_EXCHANGE = 1,
ACTIVE_CALL = 2,
END_CALL = 3,
CREATE_ROOM = 4,
/**
* Переведен в стадию активного, значит комната на SFU уже создана и можно начинать обмен сигналами WebRTC
*/
ACTIVE = 4,
END_CALL_BECAUSE_PEER_DISCONNECTED = 5,
END_CALL_BECAUSE_BUSY = 6
END_CALL_BECAUSE_BUSY = 6,
ACCEPT = 7,
RINGING_TIMEOUT = 8
}
/**
@@ -28,12 +33,8 @@ export class PacketSignalPeer extends Packet {
private signalType: SignalType = SignalType.CALL;
/**
* Используется если SignalType == CREATE_ROOM,
* для идентификации комнаты на SFU сервере, в которой будет происходить обмен сигналами
* WebRTC для установления P2P соединения между участниками звонка
*/
private roomId: string = "";
private callId: string = "";
private joinToken: string = "";
public getPacketId(): number {
@@ -42,7 +43,9 @@ export class PacketSignalPeer extends Packet {
public _receive(stream: Stream): void {
this.signalType = stream.readInt8();
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY
|| this.signalType == SignalType.RINGING_TIMEOUT
|| this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
return;
}
this.src = stream.readString();
@@ -50,8 +53,9 @@ export class PacketSignalPeer extends Packet {
if(this.signalType == SignalType.KEY_EXCHANGE){
this.sharedPublic = stream.readString();
}
if(this.signalType == SignalType.CREATE_ROOM){
this.roomId = stream.readString();
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
this.callId = stream.readString();
this.joinToken = stream.readString();
}
}
@@ -59,7 +63,9 @@ export class PacketSignalPeer extends Packet {
const stream = new Stream();
stream.writeInt16(this.getPacketId());
stream.writeInt8(this.signalType);
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY
|| this.signalType == SignalType.RINGING_TIMEOUT
|| this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
return stream;
}
stream.writeString(this.src);
@@ -67,8 +73,9 @@ export class PacketSignalPeer extends Packet {
if(this.signalType == SignalType.KEY_EXCHANGE){
stream.writeString(this.sharedPublic);
}
if(this.signalType == SignalType.CREATE_ROOM){
stream.writeString(this.roomId);
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
stream.writeString(this.callId);
stream.writeString(this.joinToken);
}
return stream;
}
@@ -105,12 +112,20 @@ export class PacketSignalPeer extends Packet {
this.src = src;
}
public getRoomId(): string {
return this.roomId;
public getCallId(): string {
return this.callId;
}
public setRoomId(roomId: string) {
this.roomId = roomId;
public setCallId(callId: string) {
this.callId = callId;
}
public getJoinToken(): string {
return this.joinToken;
}
public setJoinToken(joinToken: string) {
this.joinToken = joinToken;
}
}

View File

@@ -18,14 +18,14 @@ export class PacketSync extends Packet {
public _receive(stream: Stream): void {
this.status = stream.readInt8() as SyncStatus;
this.timestamp = stream.readInt64();
this.timestamp = Number(stream.readInt64());
}
public _send(): Promise<Stream> | Stream {
let stream = new Stream();
stream.writeInt16(this.getPacketId());
stream.writeInt8(this.status);
stream.writeInt64(this.timestamp);
stream.writeInt64(BigInt(this.timestamp));
return stream;
}

View File

@@ -1,151 +1,372 @@
export default class Stream {
private _stream: number[];
private _readPoiner: number = 0;
private _writePointer: number = 0;
private stream: Uint8Array;
private readPointer = 0; // bits
private writePointer = 0; // bits
constructor(stream : number[] = []) {
this._stream = stream;
constructor(stream?: Uint8Array | number[]) {
if (!stream) {
this.stream = new Uint8Array(0);
} else {
const src = stream instanceof Uint8Array ? stream : Uint8Array.from(stream);
this.stream = src;
this.writePointer = this.stream.length << 3;
}
}
getStream(): Uint8Array {
return this.stream.slice(0, this.length());
}
setStream(stream?: Uint8Array | number[]) {
if (!stream) {
this.stream = new Uint8Array(0);
this.readPointer = 0;
this.writePointer = 0;
return;
}
const src = stream instanceof Uint8Array ? stream : Uint8Array.from(stream);
this.stream = src;
this.readPointer = 0;
this.writePointer = this.stream.length << 3;
}
getBuffer(): Uint8Array {
return this.getStream();
}
isEmpty(): boolean {
return this.writePointer === 0;
}
length(): number {
return (this.writePointer + 7) >> 3;
}
// ---------- bit / boolean ----------
writeBit(value: number) {
this.writeBits(BigInt(value & 1), 1);
}
readBit(): number {
return Number(this.readBits(1));
}
writeBoolean(value: boolean) {
this.writeBit(value ? 1 : 0);
}
readBoolean(): boolean {
return this.readBit() === 1;
}
// ---------- byte ----------
writeByte(b: number) {
this.writeUInt8(b & 0xff);
}
readByte(): number {
const v = this.readUInt8();
return (v << 24) >> 24; // signed byte
}
// ---------- UInt / Int 8 ----------
writeUInt8(value: number) {
const v = value & 0xff;
if ((this.writePointer & 7) === 0) {
this.reserveBits(8);
this.stream[this.writePointer >> 3] = v;
this.writePointer += 8;
return;
}
public getStream(): number[] {
return this._stream;
this.writeBits(BigInt(v), 8);
}
readUInt8(): number {
if (this.remainingBits() < 8n) {
throw new Error("Not enough bits to read UInt8");
}
public setStream(stream: number[]) {
this._stream = stream;
if ((this.readPointer & 7) === 0) {
const v = this.stream[this.readPointer >> 3] & 0xff;
this.readPointer += 8;
return v;
}
public writeInt8(value: number) {
const negationBit = value < 0 ? 1 : 0;
const int8Value = Math.abs(value) & 0xFF;
this._stream[this._writePointer >> 3] |= negationBit << (7 - (this._writePointer & 7));
this._writePointer++;
for (let i = 0; i < 8; i++) {
const bit = (int8Value >> (7 - i)) & 1;
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
this._writePointer++;
}
return Number(this.readBits(8));
}
writeInt8(value: number) {
this.writeUInt8(value);
}
readInt8(): number {
const u = this.readUInt8();
return (u << 24) >> 24;
}
// ---------- UInt / Int 16 ----------
writeUInt16(value: number) {
const v = value & 0xffff;
this.writeUInt8((v >>> 8) & 0xff);
this.writeUInt8(v & 0xff);
}
readUInt16(): number {
const hi = this.readUInt8();
const lo = this.readUInt8();
return (hi << 8) | lo;
}
writeInt16(value: number) {
this.writeUInt16(value);
}
readInt16(): number {
const u = this.readUInt16();
return (u << 16) >> 16;
}
// ---------- UInt / Int 32 ----------
writeUInt32(value: number) {
if (!Number.isFinite(value) || value < 0 || value > 0xffffffff) {
throw new Error(`UInt32 out of range: ${value}`);
}
public readInt8(): number {
let value = 0;
const negationBit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
this._readPoiner++;
for (let i = 0; i < 8; i++) {
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
value |= bit << (7 - i);
this._readPoiner++;
}
return negationBit ? -value : value;
const v = Math.floor(value);
this.writeUInt8((v >>> 24) & 0xff);
this.writeUInt8((v >>> 16) & 0xff);
this.writeUInt8((v >>> 8) & 0xff);
this.writeUInt8(v & 0xff);
}
readUInt32(): number {
const b1 = this.readUInt8();
const b2 = this.readUInt8();
const b3 = this.readUInt8();
const b4 = this.readUInt8();
return (((b1 * 0x1000000) + (b2 << 16) + (b3 << 8) + b4) >>> 0);
}
writeInt32(value: number) {
this.writeUInt32(value >>> 0);
}
readInt32(): number {
return this.readUInt32() | 0;
}
// ---------- UInt / Int 64 ----------
writeUInt64(value: bigint) {
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) {
throw new Error(`UInt64 out of range: ${value.toString()}`);
}
public writeBit(value: number) {
const bit = value & 1;
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
this._writePointer++;
this.writeUInt8(Number((value >> 56n) & 0xffn));
this.writeUInt8(Number((value >> 48n) & 0xffn));
this.writeUInt8(Number((value >> 40n) & 0xffn));
this.writeUInt8(Number((value >> 32n) & 0xffn));
this.writeUInt8(Number((value >> 24n) & 0xffn));
this.writeUInt8(Number((value >> 16n) & 0xffn));
this.writeUInt8(Number((value >> 8n) & 0xffn));
this.writeUInt8(Number(value & 0xffn));
}
readUInt64(): bigint {
const high = BigInt(this.readUInt32() >>> 0);
const low = BigInt(this.readUInt32() >>> 0);
return (high << 32n) | low;
}
writeInt64(value: bigint) {
const u = BigInt.asUintN(64, value);
this.writeUInt64(u);
}
readInt64(): bigint {
return BigInt.asIntN(64, this.readUInt64());
}
// ---------- float ----------
writeFloat32(value: number) {
const ab = new ArrayBuffer(4);
const dv = new DataView(ab);
dv.setFloat32(0, value, false); // big-endian
this.writeUInt8(dv.getUint8(0));
this.writeUInt8(dv.getUint8(1));
this.writeUInt8(dv.getUint8(2));
this.writeUInt8(dv.getUint8(3));
}
readFloat32(): number {
const ab = new ArrayBuffer(4);
const dv = new DataView(ab);
dv.setUint8(0, this.readUInt8());
dv.setUint8(1, this.readUInt8());
dv.setUint8(2, this.readUInt8());
dv.setUint8(3, this.readUInt8());
return dv.getFloat32(0, false); // big-endian
}
// ---------- string / bytes ----------
// String: length(UInt32) + chars(UInt16), как в Java charAt()
writeString(value: string | null | undefined) {
const s = value ?? "";
this.writeUInt32(s.length);
if (s.length === 0) return;
this.reserveBits(BigInt(s.length) * 16n);
for (let i = 0; i < s.length; i++) {
this.writeUInt16(s.charCodeAt(i) & 0xffff);
}
}
readString(): string {
const len = this.readUInt32();
if (len > 0x7fffffff) {
throw new Error(`String length too large: ${len}`);
}
public readBit(): number {
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
this._readPoiner++;
return bit;
const requiredBits = BigInt(len) * 16n;
if (requiredBits > this.remainingBits()) {
throw new Error("Not enough bits to read string");
}
public writeBoolean(value: boolean) {
this.writeBit(value ? 1 : 0);
const chars = new Array<number>(len);
for (let i = 0; i < len; i++) {
chars[i] = this.readUInt16();
}
return String.fromCharCode(...chars);
}
// byte[]: length(UInt32) + payload
writeBytes(value: Uint8Array | number[] | null | undefined) {
const arr = value == null
? new Uint8Array(0)
: (value instanceof Uint8Array ? value : Uint8Array.from(value));
this.writeUInt32(arr.length);
if (arr.length === 0) return;
this.reserveBits(BigInt(arr.length) * 8n);
if ((this.writePointer & 7) === 0) {
const byteIndex = this.writePointer >> 3;
this.ensureCapacity(byteIndex + arr.length - 1);
this.stream.set(arr, byteIndex);
this.writePointer += arr.length << 3;
return;
}
public readBoolean(): boolean {
return this.readBit() === 1;
for (let i = 0; i < arr.length; i++) {
this.writeUInt8(arr[i]);
}
public writeInt16(value: number) {
this.writeInt8(value >> 8);
this.writeInt8(value & 0xFF);
}
readBytes(): Uint8Array {
const len = this.readUInt32();
if (len === 0) return new Uint8Array(0);
const requiredBits = BigInt(len) * 8n;
if (requiredBits > this.remainingBits()) {
return new Uint8Array(0);
}
public readInt16(): number {
const value = this.readInt8() << 8;
return value | this.readInt8();
const out = new Uint8Array(len);
if ((this.readPointer & 7) === 0) {
const byteIndex = this.readPointer >> 3;
out.set(this.stream.slice(byteIndex, byteIndex + len));
this.readPointer += len << 3;
return out;
}
public writeInt32(value: number) {
this.writeInt16(value >> 16);
this.writeInt16(value & 0xFFFF);
for (let i = 0; i < len; i++) {
out[i] = this.readUInt8();
}
return out;
}
// ---------- internals ----------
private remainingBits(): bigint {
return BigInt(this.writePointer - this.readPointer);
}
private writeBits(value: bigint, bits: number) {
if (bits <= 0) return;
this.reserveBits(bits);
for (let i = bits - 1; i >= 0; i--) {
const bit = Number((value >> BigInt(i)) & 1n);
const byteIndex = this.writePointer >> 3;
const shift = 7 - (this.writePointer & 7);
if (bit === 1) {
this.stream[byteIndex] = this.stream[byteIndex] | (1 << shift);
} else {
this.stream[byteIndex] = this.stream[byteIndex] & ~(1 << shift);
}
this.writePointer++;
}
}
private readBits(bits: number): bigint {
if (bits <= 0) return 0n;
if (this.remainingBits() < BigInt(bits)) {
throw new Error("Not enough bits to read");
}
public readInt32(): number {
const value = this.readInt16() << 16;
return value | this.readInt16();
let value = 0n;
for (let i = 0; i < bits; i++) {
const bit = (this.stream[this.readPointer >> 3] >> (7 - (this.readPointer & 7))) & 1;
value = (value << 1n) | BigInt(bit);
this.readPointer++;
}
return value;
}
private reserveBits(bitsToWrite: number | bigint) {
const bits = typeof bitsToWrite === "number" ? BigInt(bitsToWrite) : bitsToWrite;
if (bits <= 0n) return;
const lastBitIndex = BigInt(this.writePointer) + bits - 1n;
if (lastBitIndex < 0n) throw new Error("Bit index overflow");
const byteIndex = lastBitIndex >> 3n;
if (byteIndex > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error("Stream too large");
}
public writeInt64(value: number) {
const high = Math.floor(value / 0x100000000);
const low = value >>> 0;
this.writeInt32(high);
this.writeInt32(low);
}
public readInt64(): number {
const high = this.readInt32();
const low = this.readInt32() >>> 0;
return high * 0x100000000 + low;
}
public writeFloat32(value: number) {
const buffer = new ArrayBuffer(4);
new DataView(buffer).setFloat32(0, value, true);
const float32Value = new Uint32Array(buffer)[0];
this.writeInt32(float32Value);
}
public readFloat32(): number {
const float32Value = this.readInt32();
const buffer = new ArrayBuffer(4);
new Uint32Array(buffer)[0] = float32Value;
return new DataView(buffer).getFloat32(0, true);
}
public writeString(value: string) {
let length = value.length;
this.writeInt32(length);
for (let i = 0; i < value.length; i++) {
this.writeInt16(value.charCodeAt(i));
}
}
public readString(): string {
let length = this.readInt32();
/**
* Фикс уязвимости с длинной строки, превышающей
* возможность для чтения _stream
*/
if (length < 0 || length > (this._stream.length - (this._readPoiner >> 3))) {
console.info("Stream readString length invalid", length, this._stream.length, this._readPoiner);
return "";
}
let value = "";
for (let i = 0; i < length; i++) {
value += String.fromCharCode(this.readInt16());
}
return value;
}
public writeBytes(value: number[]) {
this.writeInt32(value.length);
for (let i = 0; i < value.length; i++) {
this.writeInt8(value[i]);
}
}
public readBytes(): number[] {
let length = this.readInt32();
let value : any = [];
for (let i = 0; i < length; i++) {
value.push(this.readInt8());
}
return value;
this.ensureCapacity(Number(byteIndex));
}
private ensureCapacity(byteIndex: number) {
const requiredSize = byteIndex + 1;
if (requiredSize <= this.stream.length) return;
let newSize = this.stream.length === 0 ? 32 : this.stream.length;
while (newSize < requiredSize) {
if (newSize > (0x7fffffff >> 1)) {
newSize = requiredSize;
break;
}
newSize <<= 1;
}
const next = new Uint8Array(newSize);
next.set(this.stream);
this.stream = next;
}
}

View File

@@ -7,7 +7,7 @@ import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
interface TransportContextValue {
transportServer: string | null;
uploadFile: (id: string, content: string) => Promise<any>;
downloadFile: (id: string, tag: string) => Promise<string>;
downloadFile: (id: string, tag: string, transportServer: string) => Promise<string>;
uploading: TransportState[];
downloading: TransportState[];
}
@@ -86,14 +86,14 @@ export function TransportProvider(props: TransportProviderProps) {
* @param tag тег файла
* @param chachaDecryptedKey ключ для расшифровки файла
*/
const downloadFile = (id: string, tag : string) : Promise<string> => {
const downloadFile = (id: string, tag : string, transportServer: string) : Promise<string> => {
return new Promise((resolve, reject) => {
if (!transportServerRef.current) {
if (!transportServer) {
throw new Error("Transport server is not set");
}
setDownloading(prev => [...prev, { id: id, progress: 0 }]);
const xhr = new XMLHttpRequest();
xhr.open('GET', `${transportServerRef.current}/d/${tag}`);
xhr.open('GET', `${transportServer}/d/${tag}`);
xhr.responseType = 'text';
xhr.onprogress = (event) => {

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
import { TransportContext } from "./TransportProvider";
export function useTransportServer() {
const context = useContext(TransportContext);
if(!context){
throw new Error("useTransportServer must be used within a TransportProvider");
}
const { transportServer } = context;
return transportServer;
}

View File

@@ -15,6 +15,10 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
return "$a=File";
case AttachmentType.AVATAR:
return "$a=Avatar";
case AttachmentType.CALL:
return "$a=Call";
case AttachmentType.VOICE:
return "$a=Voice message";
default:
return "[Unsupported attachment]";
}

View File

@@ -1,8 +1,7 @@
export const APP_VERSION = "1.1.1";
export const CORE_MIN_REQUIRED_VERSION = "1.5.3";
export const APP_VERSION = "1.2.2";
export const CORE_MIN_REQUIRED_VERSION = "1.5.5";
export const RELEASE_NOTICE = `
**Обновление v1.1.1** :emoji_1f631:
- Добавлено сквозное шифрование звонков
- Исправлена проблема с звуком в звонках на некоторых устройствах
**Обновление v1.2.2** :emoji_1f631:
- Поддержка записи и прослушивания голосовых сообщений
`;

View File

@@ -1,4 +1,4 @@
import { ColorSwatch, Text, useComputedColorScheme } from "@mantine/core";
import { Button, ColorSwatch, Flex, Text, useComputedColorScheme } from "@mantine/core";
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
import { useState } from "react";
import { Breadcrumbs } from "@/app/components/Breadcrumbs/Breadcrumbs";
@@ -17,6 +17,7 @@ import { SettingsIcon } from "@/app/components/SettingsIcon/SettingsIcon";
import { IconBrush, IconHomeCog, IconLogout, IconRefresh } from "@tabler/icons-react";
import { useLogout } from "@/app/providers/AccountProvider/useLogout";
import { RosettaPower } from "@/app/components/RosettaPower/RosettaPower";
import { modals } from "@mantine/modals";
export function MyProfile() {
const publicKey = usePublicKey();
@@ -28,8 +29,34 @@ export function MyProfile() {
const navigate = useNavigate();
const send = useSender();
const logout = useLogout();
const [usernameError, setUsernameError] = useState(false);
const openProfileModal = (text : string) => {
modals.open({
centered: true,
children: (
<>
<Text size="sm">
{text}
</Text>
<Flex align={'center'} justify={'flex-end'}>
<Button style={{
outline: 'none'
}} color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
Close
</Button>
</Flex>
</>
),
withCloseButton: false
});
}
const saveProfileData = () => {
if(usernameError) {
openProfileModal("You enter invalid username. Username must be a latin chars in lowercase.");
return;
}
let packet = new PacketUserInfo();
packet.setUsername(username);
packet.setTitle(title);
@@ -70,10 +97,13 @@ export function MyProfile() {
<SettingsInput.Default
hit="Username"
value={username}
onErrorStateChange={(error) => setUsernameError(error)}
placeholder="ex. freddie871"
onChange={(e) => setUsername(e.target.value)}
regexp={new RegExp(/^([a-z][a-z0-9_]{4,15})?$/)}
></SettingsInput.Default>
</SettingsInput.Group>
{usernameError && <Text c={'red'} fz={10} pl={'xs'} mt={3}>Invalid username.</Text>}
<SettingsInput.Copy mt={'sm'} hit="Public Key" value={
publicKey
} placeholder="Public"></SettingsInput.Copy>

View File

@@ -28,12 +28,12 @@ export function createPreloaderWindow() {
export function createAppWindow(preloaderWindow?: BrowserWindow): void {
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
width: 385,
height: 555,
minWidth: 385,
minHeight: 555,
show: false,
title: 'Rosetta Messager',
title: 'Rosetta Messager',
icon: join(__dirname, '../../resources/R.png'),
frame: false,
autoHideMenuBar: true,
@@ -92,7 +92,7 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
ipcMain.removeHandler('window-priority-normal');
ipcMain.handle('window-top', () => {
if (mainWindow.isMinimized()){
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.setAlwaysOnTop(true, "screen-saver"); // самый высокий уровень
@@ -112,7 +112,7 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
ipcMain.handle('window-priority-normal', () => {
mainWindow.setAlwaysOnTop(false);
mainWindow.setVisibleOnAllWorkspaces(false);
if(process.platform === "darwin" && bounceId !== null){
if (process.platform === "darwin" && bounceId !== null) {
/**
* Только в macos! Отмена подпрыгивания иконки в Dock
*/

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, Menu, nativeImage } from 'electron'
import { app, BrowserWindow, Menu, Tray, nativeImage } from 'electron'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { createAppWindow, startApplication } from './app'
import './ipcs/ipcDatabase'
@@ -9,96 +9,137 @@ import './ipcs/ipcNotification'
import './ipcs/ipcDevice'
import './ipcs/ipcCore'
import './ipcs/ipcRuntime'
import { Tray } from 'electron/main'
import { join } from 'path'
import { Logger } from './logger'
let lockInstance = app.requestSingleInstanceLock();
let tray : Tray | null = null;
const size = process.platform === 'darwin' ? 18 : 22;
const logger = Logger('main');
const lockInstance = app.requestSingleInstanceLock()
let tray: Tray | null = null
const size = process.platform === 'darwin' ? 18 : 22
const logger = Logger('main')
const icon = nativeImage
.createFromPath(join(__dirname, '../../resources/R.png'))
.resize({ width: size, height: size })
const icon = nativeImage.createFromPath(
join(__dirname, '../../resources/R.png')
).resize({ width: size, height: size });
if(!lockInstance){
app.quit();
process.exit(0);
if (!lockInstance) {
app.quit()
process.exit(0)
}
process.on('unhandledRejection', (reason) => {
logger.log(`main thread error, reason: ${reason}`);
});
logger.log(`main thread error, reason: ${reason}`)
})
app.disableHardwareAcceleration();
app.disableHardwareAcceleration()
app.on('second-instance', () => {
// Someone tried to run a second instance, we should focus our window.
const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length) {
const mainWindow = allWindows[0];
if (mainWindow.isMinimized()) mainWindow.restore();
if (mainWindow.isVisible() === false) mainWindow.show();
mainWindow.focus();
}
});
export const restoreApplicationAfterClickOnTrayOrDock = () => {
const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length > 0) {
const mainWindow = allWindows[0];
if (mainWindow.isMinimized()){
mainWindow.restore();
return;
}
if(mainWindow.isVisible() === false){
mainWindow.show();
}
mainWindow.focus();
} else {
createAppWindow();
}
}
//Menu.setApplicationMenu(null);
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
electronApp.setAppUserModelId('Rosetta');
tray = new Tray(icon);
const contextMenu = Menu.buildFromTemplate([
{ label: 'Open App', click: () => restoreApplicationAfterClickOnTrayOrDock() },
{ label: 'Quit', click: () => app.quit() }
]);
tray.setContextMenu(contextMenu);
tray.setToolTip('Rosetta');
tray.on('click', () => {
restoreApplicationAfterClickOnTrayOrDock();
});
startApplication();
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
app.on('activate', function () {
restoreApplicationAfterClickOnTrayOrDock();
});
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform == 'darwin') {
app.hide();
const allWindows = BrowserWindow.getAllWindows()
if (allWindows.length) {
const mainWindow = allWindows[0]
if (mainWindow.isMinimized()) mainWindow.restore()
if (!mainWindow.isVisible()) mainWindow.show()
mainWindow.focus()
}
})
// In this file, you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.
export const restoreApplicationAfterClickOnTrayOrDock = () => {
const allWindows = BrowserWindow.getAllWindows()
if (allWindows.length > 0) {
const mainWindow = allWindows[0]
if (mainWindow.isMinimized()) {
mainWindow.restore()
return
}
if (!mainWindow.isVisible()) {
mainWindow.show()
}
mainWindow.focus()
} else {
createAppWindow()
}
}
app.whenReady().then(async () => {
electronApp.setAppUserModelId('Rosetta')
// Убираем File/View и оставляем только app + минимальный Edit (roles)
if (process.platform === 'darwin') {
const minimalMenu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' }
]
}
])
Menu.setApplicationMenu(minimalMenu)
} else {
Menu.setApplicationMenu(null)
}
tray = new Tray(icon)
const contextMenu = Menu.buildFromTemplate([
{ label: 'Open App', click: () => restoreApplicationAfterClickOnTrayOrDock() },
{ label: 'Quit', click: () => app.quit() }
])
tray.setContextMenu(contextMenu)
tray.setToolTip('Rosetta')
tray.on('click', () => {
restoreApplicationAfterClickOnTrayOrDock()
})
startApplication()
const isDevBuild =
!app.isPackaged ||
process.env.NODE_ENV === 'development' ||
Boolean(process.env.ELECTRON_RENDERER_URL)
app.on('browser-window-created', (_, window) => {
// В production оставляем стандартную защиту шорткатов
if (!isDevBuild) {
optimizer.watchWindowShortcuts(window)
return
}
// В dev явно разрешаем Ctrl+R и Cmd+R для перезагрузки, так как в режиме разработки это часто нужно
window.webContents.on('before-input-event', (event, input) => {
const key = input.key?.toLowerCase?.() ?? ''
const isReload = input.type === 'keyDown' && (input.meta || input.control) && key === 'r'
if (isReload) {
event.preventDefault()
window.webContents.reloadIgnoringCache()
}
})
})
app.on('activate', () => {
restoreApplicationAfterClickOnTrayOrDock()
})
})
app.on('window-all-closed', () => {
if (process.platform === 'darwin') {
app.hide()
}
})

View File

@@ -1,13 +1,16 @@
{
"name": "Rosetta",
"version": "1.5.3",
"version": "1.5.5",
"description": "Rosetta Messenger",
"main": "./out/main/main.js",
"license": "MIT",
"build": {
"electronUpdaterCompatibility": false,
"extraResources": [
{ "from": "resources/", "to": "resources/" }
{
"from": "resources/",
"to": "resources/"
}
],
"files": [
"node_modules/sqlite3/**/*",
@@ -81,7 +84,7 @@
"@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.3.12",
"@noble/ciphers": "^1.2.1",
"@noble/ciphers": "^1.3.0",
"@noble/secp256k1": "^3.0.0",
"@tabler/icons-react": "^3.31.0",
"@types/crypto-js": "^4.2.2",
@@ -108,6 +111,8 @@
"jsencrypt": "^3.3.2",
"jszip": "^3.10.1",
"libsodium": "^0.8.2",
"libsodium-wrappers": "^0.8.2",
"libsodium-wrappers-sumo": "^0.8.2",
"lottie-react": "^2.4.1",
"node-forge": "^1.3.1",
"node-machine-id": "^1.1.12",
@@ -133,6 +138,7 @@
"@electron/rebuild": "^4.0.3",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/vite": "^4.0.9",
"@types/libsodium-wrappers": "^0.7.14",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",

10
problems/problem_calls.md Normal file
View File

@@ -0,0 +1,10 @@
# Проблема обмена ключами в звонках
Для того, чтобы звонки работали защищенно, необходимо обмениваться ключами между участниками. При этом, сам ключ не должен передаваться по сети, иначе его смогут перехватить злоумышленники. Поэтому, для обмена ключами используется специальный протокол, который позволяет участникам обмениваться ключами без передачи их по сети. В Rosetta можно было использовать уже известный и проверенный контур шифрования по которому шифруются сообщения с использованием публичных и приватных ключей. Однако реализация такого метода может обернуться проблемами при эксплуатации, так как при ответе на звонок, участник может не иметь доступа к приватному ключу, который используется для дешифровки сообщений (например, при ответе на звонок с телефона, когда в приложение не был совершен вход, то есть оно выгружено из памяти), в этом случае звонок не будет работать, так как участник не сможет дешифровать голос и видео из звонка.
## Возможное решение
Можно заставлять пользователя входить в приложение при ответе на звонок, чтобы он мог получить доступ к приватному ключу и дешифровать звонок. Однако, это может привести к неудобствам для пользователей, так как им придется каждый раз входить в приложение при ответе на звонок, что может быть особенно проблематично при использовании мобильного устройства.
## Решение использованное в Rosetta
Для решения проблемы обмена ключами в звонках, в Rosetta используется алгоритм Диффи-Хеллмана для генерации общего секрета между участниками звонка. Этот алгоритм позволяет участникам обмениваться публичными ключами и генерировать общий секрет, который используется для шифрования и дешифрования медиа-потока в звонке. При этом, это не требует входа в приложение, так как ключ генерируется случайный при каждом звонке, и не зависит от приватного ключа. Это обеспечивает удобство для пользователей, так как им не нужно входить в приложение при ответе на звонок, и при этом обеспечивает безопасность звонков, так как ключи не передаются по сети и генерируются случайным образом для каждого звонка. Таким образом, Rosetta обеспечивает безопасные и удобные звонки для пользователей.