Compare commits
118 Commits
e79282755b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4592d03b0 | ||
|
|
6554483939 | ||
|
|
ba12db3c72 | ||
|
|
b596d36543 | ||
|
|
93ef692eb5 | ||
|
|
8fdfe9b786 | ||
|
|
547ac89987 | ||
|
|
130ad9c35a | ||
|
|
adfc6add6f | ||
|
|
cf29cecfd6 | ||
|
|
779c265851 | ||
|
|
8ac952071d | ||
|
|
e1f5cb7eb8 | ||
|
|
30f2c90015 | ||
|
|
a341aedd8d | ||
|
|
a9164c7087 | ||
|
|
04dd23dd5c | ||
|
|
5979c31120 | ||
|
|
c8c85991c7 | ||
|
|
c052fdae41 | ||
|
|
3492a881cc | ||
|
|
febeb58778 | ||
|
|
93e4898bec | ||
|
|
de7a00f37a | ||
|
|
7b3dd6c566 | ||
|
|
70af076248 | ||
|
|
92c9dc03c9 | ||
|
|
7e8d086a74 | ||
|
|
0a0c810105 | ||
|
|
8fbfb4fa5c | ||
|
|
2b9e28ee4a | ||
|
|
d2a506119c | ||
|
|
269f66fdc5 | ||
|
|
5113d18d70 | ||
|
|
cd2dee21ab | ||
|
|
1b14463dbb | ||
|
|
d23ca97be9 | ||
|
|
519aa8802f | ||
|
|
2f2a0b5376 | ||
|
|
61e83bdd43 | ||
|
|
f5bfa153b6 | ||
|
|
81f5e66c56 | ||
|
|
aaa4b4283a | ||
|
|
c9cff515e5 | ||
|
|
94ba139541 | ||
|
|
7e0e97f472 | ||
|
|
8d6090e632 | ||
|
|
fd3fac54f6 | ||
|
|
bd3c0eec69 | ||
|
|
697b797f8c | ||
|
|
429aa614d7 | ||
|
|
e727529b89 | ||
|
|
786d5428f8 | ||
|
|
e4da2510cc | ||
|
|
0e5384b908 | ||
| 013a5d9f17 | |||
|
|
f997581c23 | ||
|
|
1333eb40ce | ||
|
|
57be4631f2 | ||
|
|
7c2718ff9a | ||
|
|
40b2d9c3f0 | ||
|
|
13d52c694f | ||
|
|
27f011ec61 | ||
|
|
7e977b762f | ||
|
|
6dc35d7cca | ||
|
|
a1c8b3d95a | ||
|
|
d2e574d186 | ||
|
|
2e9ccf9c6e | ||
|
|
1eca665a62 | ||
|
|
319ff7baf1 | ||
|
|
e61c9c5f58 | ||
|
|
426f0c40bc | ||
|
|
b300fa4d03 | ||
| 04cd27a5f3 | |||
| d29fe317a8 | |||
|
|
329e6d7825 | ||
|
|
bd3411de52 | ||
|
|
6f95f326bf | ||
|
|
91b955d621 | ||
|
|
e019702dbe | ||
|
|
48e0cddbaa | ||
|
|
98fbabc130 | ||
|
|
0c823c398f | ||
|
|
1d6c30fb08 | ||
|
|
0d70824d77 | ||
|
|
4df39cb83d | ||
|
|
f269046c46 | ||
|
|
5032d92f8e | ||
| d4680ab6fc | |||
|
|
e5a4c92ba7 | ||
|
|
f91392e6aa | ||
|
|
46af6661a1 | ||
|
|
59d40e3005 | ||
|
|
d3cda685cd | ||
|
|
427f2e9e33 | ||
|
|
9f8840e077 | ||
| 523d67b01f | |||
|
|
8f0e8e8251 | ||
|
|
61d55f266f | ||
|
|
824b1fec65 | ||
|
|
41d7a89830 | ||
|
|
88288317ab | ||
|
|
7b9936dcc4 | ||
|
|
fcf4204063 | ||
|
|
6dd348230f | ||
|
|
2c026d596d | ||
|
|
ab57303eb6 | ||
|
|
f57ec484e3 | ||
|
|
f0d0909382 | ||
|
|
76442c4161 | ||
|
|
0513a90036 | ||
|
|
0600da5b7c | ||
|
|
8dc2537cdc | ||
|
|
2707bd2a39 | ||
|
|
ca36a8d818 | ||
| e2b767779f | |||
| 6a0a97798d | |||
| 1c8493b33f |
@@ -1,4 +1,5 @@
|
|||||||
name: MacOS Kernel Build
|
name: MacOS Kernel Build
|
||||||
|
run-name: Build and Upload MacOS Kernel
|
||||||
|
|
||||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
|
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
|
||||||
on:
|
on:
|
||||||
@@ -12,6 +13,10 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos
|
runs-on: macos
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
arch: [x64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -30,6 +35,7 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-npm-
|
${{ runner.os }}-npm-
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
# Кэш для electron-builder
|
# Кэш для electron-builder
|
||||||
- name: Cache electron-builder
|
- name: Cache electron-builder
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
@@ -41,32 +47,36 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-electron-builder-
|
${{ runner.os }}-electron-builder-
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
- name: NPM offline setup
|
- name: NPM offline setup
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p dist/builds/darwin/arm64 dist/builds/darwin/x64
|
||||||
npm config set cache "$HOME/.npm-cache" --global
|
npm config set cache "$HOME/.npm-cache" --global
|
||||||
npm config set prefer-offline true --global
|
npm config set prefer-offline true --global
|
||||||
|
|
||||||
- name: Install npm dependencies
|
- name: Install npm dependencies
|
||||||
run: npm install --prefer-offline --no-audit --no-fund
|
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 }}'
|
|
||||||
|
|
||||||
|
- name: Build the application
|
||||||
|
run: |
|
||||||
|
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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
name: Linux Kernel Build
|
name: Linux Kernel Build
|
||||||
|
run-name: Build and Upload Linux Kernel
|
||||||
|
|
||||||
#Запускаем только кнопкой "Run workflow" в Actions
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
@@ -11,7 +11,16 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
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:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -21,65 +30,35 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
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
|
- name: Install npm dependencies
|
||||||
run: npm install --no-audit --no-fund
|
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
|
- 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
|
- name: Check if files exist
|
||||||
run: |
|
run: |
|
||||||
echo "=== Checking dist structure ==="
|
echo "=== Checking dist structure ==="
|
||||||
find dist/builds -type f -name "*.AppImage" 2>/dev/null || echo "No AppImage files 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/ 2>/dev/null || echo "linux folder not found"
|
ls -la dist/builds/linux/${{ matrix.out_dir }}/ 2>/dev/null || echo "arch folder not found"
|
||||||
|
|
||||||
- name: Install SCP in Docker container
|
- name: Upload to SSH using SCP
|
||||||
run: apt-get install -y openssh-client
|
|
||||||
|
|
||||||
- name: Upload x64 to SSH using SCP
|
|
||||||
uses: appleboy/scp-action@master
|
uses: appleboy/scp-action@master
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.SDU_SSH_HOST }}
|
host: ${{ secrets.SDU_SSH_HOST }}
|
||||||
username: ${{ secrets.SDU_SSH_USERNAME }}
|
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||||
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||||
port: 22
|
port: 22
|
||||||
source: "dist/builds/linux/x64/Rosetta-*.AppImage"
|
source: dist/builds/linux/${{ matrix.out_dir }}/Rosetta-*.AppImage
|
||||||
target: "${{ secrets.SDU_SSH_KERNEL }}/linux/x64"
|
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
|
strip_components: 4
|
||||||
rm: true
|
rm: true
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
name: SP Builds
|
name: SP Builds
|
||||||
|
run-name: Build and Upload SP Packages
|
||||||
|
|
||||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
@@ -11,35 +11,14 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
# Кэш npm (тарифы грузятся из ~/.npm-cache на macOS)
|
- name: Checkout code
|
||||||
- name: Cache npm cache
|
uses: actions/checkout@v6
|
||||||
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
|
|
||||||
|
|
||||||
- name: NPM offline setup
|
- name: NPM offline setup
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -54,6 +33,11 @@ jobs:
|
|||||||
- name: Build the application
|
- name: Build the application
|
||||||
run: npm run kernel:linux
|
run: npm run kernel:linux
|
||||||
|
|
||||||
|
- name: Install ZIP in Docker container
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y zip
|
||||||
|
|
||||||
#Собираем сервисные пакеты для всех платформ
|
#Собираем сервисные пакеты для всех платформ
|
||||||
- name: Build SP
|
- name: Build SP
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -62,13 +46,46 @@ jobs:
|
|||||||
sh "$GITHUB_WORKSPACE/build-packs.sh"
|
sh "$GITHUB_WORKSPACE/build-packs.sh"
|
||||||
#Загружаем на удаленный сервер по SSH используя scp и пароль из секретов
|
#Загружаем на удаленный сервер по SSH используя scp и пароль из секретов
|
||||||
#Загружаем из двух папок dist/builds/darwin/x64 и dist/builds/darwin/arm64, так как electron-builder может создавать разные файлы для разных архитектур
|
#Загружаем из двух папок 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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
|
echo "=== Workspace ==="
|
||||||
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
|
pwd
|
||||||
-l "$GITHUB_WORKSPACE/packs/*" \
|
ls -la
|
||||||
-r "${{ secrets.SDU_SSH_PACKS }}" \
|
echo "=== Packs ==="
|
||||||
-s "${{ secrets.SDU_SSH_HOST }}" \
|
find packs -maxdepth 3 -type f 2>/dev/null || true
|
||||||
-u "${{ secrets.SDU_SSH_USERNAME }}" \
|
test -n "$(find packs -type f -print -quit 2>/dev/null)" || { echo "packs is empty"; exit 1; }
|
||||||
-p '${{ secrets.SDU_SSH_PASSWORD }}'
|
|
||||||
|
- 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
|
||||||
@@ -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"
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
name: Windows Kernel Build
|
name: Windows Kernel Build
|
||||||
|
run-name: Build and Upload Windows Kernel
|
||||||
|
|
||||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build Windows
|
#Запускаем только кнопкой "Run workflow" в Actions -> Build Windows
|
||||||
#Или если есть коммпит в папку lib в ветке main
|
#Или если есть коммпит в папку lib в ветке main
|
||||||
|
|||||||
29
app/App.tsx
29
app/App.tsx
@@ -22,6 +22,7 @@ import { DialogStateProvider } from './providers/DialogStateProvider.tsx/DialogS
|
|||||||
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
|
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
|
||||||
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
|
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
|
||||||
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
|
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
|
||||||
|
import { PlayerProvider } from './providers/PlayerProvider/PlayerProvider';
|
||||||
window.Buffer = Buffer;
|
window.Buffer = Buffer;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -58,19 +59,21 @@ export default function App() {
|
|||||||
<Topbar></Topbar>
|
<Topbar></Topbar>
|
||||||
<ContextMenuProvider>
|
<ContextMenuProvider>
|
||||||
<ImageViwerProvider>
|
<ImageViwerProvider>
|
||||||
<AvatarProvider>
|
<PlayerProvider>
|
||||||
<Routes>
|
<AvatarProvider>
|
||||||
<Route path="/" element={
|
<Routes>
|
||||||
getViewByLoginState()
|
<Route path="/" element={
|
||||||
} />
|
getViewByLoginState()
|
||||||
<Route path="/create-seed" element={<CreateSeed />} />
|
} />
|
||||||
<Route path="/confirm-seed" element={<ConfirmSeed />} />
|
<Route path="/create-seed" element={<CreateSeed />} />
|
||||||
<Route path="/set-password" element={<SetPassword />} />
|
<Route path="/confirm-seed" element={<ConfirmSeed />} />
|
||||||
<Route path="/main/*" element={<Main />} />
|
<Route path="/set-password" element={<SetPassword />} />
|
||||||
<Route path="/exists-seed" element={<ExistsSeed />} />
|
<Route path="/main/*" element={<Main />} />
|
||||||
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
|
<Route path="/exists-seed" element={<ExistsSeed />} />
|
||||||
</Routes>
|
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
|
||||||
</AvatarProvider>
|
</Routes>
|
||||||
|
</AvatarProvider>
|
||||||
|
</PlayerProvider>
|
||||||
</ImageViwerProvider>
|
</ImageViwerProvider>
|
||||||
</ContextMenuProvider>
|
</ContextMenuProvider>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function ActionAvatar(props : ActionAvatarProps) {
|
|||||||
size={120}
|
size={120}
|
||||||
radius={120}
|
radius={120}
|
||||||
mx="auto"
|
mx="auto"
|
||||||
bg={'#fff'}
|
bg={avatars.length > 0 ? '#fff' : undefined}
|
||||||
name={props.title.trim() || props.publicKey}
|
name={props.title.trim() || props.publicKey}
|
||||||
color={'initials'}
|
color={'initials'}
|
||||||
src={avatars.length > 0 ?
|
src={avatars.length > 0 ?
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export function ActiveCall() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getConnectingClass = () => {
|
const getConnectingClass = () => {
|
||||||
if(callState === CallState.CONNECTING){
|
if(callState === CallState.CONNECTING
|
||||||
|
|| callState === CallState.INCOMING
|
||||||
|
|| callState === CallState.KEY_EXCHANGE
|
||||||
|
|| callState === CallState.WEB_RTC_EXCHANGE){
|
||||||
return classes.connecting;
|
return classes.connecting;
|
||||||
}
|
}
|
||||||
if(callState === CallState.ACTIVE){
|
if(callState === CallState.ACTIVE){
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ export function Call(props: CallProps) {
|
|||||||
<Flex style={{
|
<Flex style={{
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}} onClick={() => setShowCallView(false)} justify={'center'} align={'center'}>
|
}} onClick={() => setShowCallView(false)} justify={'center'} align={'center'}>
|
||||||
<IconChevronLeft size={20}></IconChevronLeft>
|
<IconChevronLeft color="white" size={20}></IconChevronLeft>
|
||||||
<Text fw={500}>Back</Text>
|
<Text fw={500} c={'white'}>Back</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex>
|
<Flex>
|
||||||
<Popover width={300} withArrow>
|
<Popover width={300} disabled={getKeyCast() == ''} withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<IconQrcode size={24}></IconQrcode>
|
<IconQrcode color={getKeyCast() == '' ? 'gray' : 'white'} size={24}></IconQrcode>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown p={'xs'}>
|
<Popover.Dropdown p={'xs'}>
|
||||||
<Flex direction={'row'} align={'center'} gap={'xs'}>
|
<Flex direction={'row'} align={'center'} gap={'xs'}>
|
||||||
|
|||||||
@@ -135,11 +135,13 @@ export function ChatHeader() {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex h={'100%'} align={'center'} gap={'sm'}>
|
<Flex h={'100%'} align={'center'} gap={'sm'}>
|
||||||
<IconPhone
|
{publicKey != opponent.publicKey && !isSystemAccount && (
|
||||||
|
<IconPhone
|
||||||
onClick={() => call(dialog)}
|
onClick={() => call(dialog)}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconPhone>
|
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconPhone>
|
||||||
|
)}
|
||||||
<IconTrashX
|
<IconTrashX
|
||||||
onClick={onClickClearMessages}
|
onClick={onClickClearMessages}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||||
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
|
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 { useEffect, useRef, useState } from "react";
|
||||||
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
||||||
import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
|
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 { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
|
||||||
import { MentionList, Mention } from "../MentionList/MentionList";
|
import { MentionList, Mention } from "../MentionList/MentionList";
|
||||||
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
|
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
|
||||||
|
import { useVoiceMessage } from "./useVoiceMessage";
|
||||||
|
import { VoiceRecorder } from "../VoiceRecorder/VoiceRecorder";
|
||||||
|
|
||||||
export function DialogInput() {
|
export function DialogInput() {
|
||||||
const colors = useRosettaColors();
|
const colors = useRosettaColors();
|
||||||
@@ -47,6 +48,7 @@ export function DialogInput() {
|
|||||||
const [mentionList, setMentionList] = useState<Mention[]>([]);
|
const [mentionList, setMentionList] = useState<Mention[]>([]);
|
||||||
const mentionHandling = useRef<string>("");
|
const mentionHandling = useRef<string>("");
|
||||||
const {getDraft, saveDraft} = useDrafts(dialog);
|
const {getDraft, saveDraft} = useDrafts(dialog);
|
||||||
|
const {start, stop, isRecording, duration, waves, getAudioBlob, interpolateCompressWaves} = useVoiceMessage();
|
||||||
|
|
||||||
|
|
||||||
const avatars = useAvatars(
|
const avatars = useAvatars(
|
||||||
@@ -64,6 +66,15 @@ export function DialogInput() {
|
|||||||
}]
|
}]
|
||||||
], [], true);
|
], [], true);
|
||||||
|
|
||||||
|
const hasText = message.trim().length > 0;
|
||||||
|
const showSendIcon = hasText || attachments.length > 0 || isRecording;
|
||||||
|
|
||||||
|
const onMicroClick = () => {
|
||||||
|
if(!isRecording) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fileDialog = useFileDialog({
|
const fileDialog = useFileDialog({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
//naccept: '*',
|
//naccept: '*',
|
||||||
@@ -89,7 +100,11 @@ export function DialogInput() {
|
|||||||
blob: fileContent,
|
blob: fileContent,
|
||||||
id: generateRandomKey(8),
|
id: generateRandomKey(8),
|
||||||
type: AttachmentType.FILE,
|
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,
|
type: AttachmentType.MESSAGES,
|
||||||
id: generateRandomKey(8),
|
id: generateRandomKey(8),
|
||||||
blob: JSON.stringify([...replyMessages.messages]),
|
blob: JSON.stringify([...replyMessages.messages]),
|
||||||
preview: ""
|
preview: "",
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
}]);
|
}]);
|
||||||
if(editableDivRef.current){
|
if(editableDivRef.current){
|
||||||
editableDivRef.current.focus();
|
editableDivRef.current.focus();
|
||||||
@@ -180,8 +199,28 @@ export function DialogInput() {
|
|||||||
mentionHandling.current = username;
|
mentionHandling.current = username;
|
||||||
}
|
}
|
||||||
|
|
||||||
const send = () => {
|
const send = async () => {
|
||||||
if(blocked || (message.trim() == "" && attachments.length <= 0)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
sendMessage(message, attachments);
|
sendMessage(message, attachments);
|
||||||
@@ -230,7 +269,11 @@ export function DialogInput() {
|
|||||||
blob: avatars[0].avatar,
|
blob: avatars[0].avatar,
|
||||||
id: generateRandomKey(8),
|
id: generateRandomKey(8),
|
||||||
type: AttachmentType.AVATAR,
|
type: AttachmentType.AVATAR,
|
||||||
preview: ""
|
preview: "",
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
}]);
|
}]);
|
||||||
if(editableDivRef.current){
|
if(editableDivRef.current){
|
||||||
editableDivRef.current.focus();
|
editableDivRef.current.focus();
|
||||||
@@ -270,7 +313,11 @@ export function DialogInput() {
|
|||||||
blob: base64Image,
|
blob: base64Image,
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
type: AttachmentType.IMAGE,
|
type: AttachmentType.IMAGE,
|
||||||
preview: ""
|
preview: "",
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
if(editableDivRef.current){
|
if(editableDivRef.current){
|
||||||
@@ -304,7 +351,11 @@ export function DialogInput() {
|
|||||||
blob: fileContent,
|
blob: fileContent,
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
type: AttachmentType.FILE,
|
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 &&
|
{!blocked &&
|
||||||
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
|
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
|
||||||
<Flex w={25} mt={10} justify={'center'}>
|
<Flex w={25} mt={10} justify={'center'}>
|
||||||
<Menu width={150} withArrow>
|
{isRecording && (
|
||||||
<Menu.Target>
|
<IconTrash onClick={stop} style={{
|
||||||
<IconPaperclip stroke={1.5} style={{
|
cursor: 'pointer'
|
||||||
cursor: 'pointer'
|
}} color={colors.error} stroke={1.5} size={25}></IconTrash>
|
||||||
}} size={25} color={colors.chevrons.active}></IconPaperclip>
|
)}
|
||||||
</Menu.Target>
|
{!isRecording && (
|
||||||
<Menu.Dropdown style={{
|
<Menu width={150} withArrow>
|
||||||
userSelect: 'none'
|
<Menu.Target>
|
||||||
}}>
|
<IconPaperclip stroke={1.5} style={{
|
||||||
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
|
cursor: 'pointer'
|
||||||
<Menu.Item fz={'xs'} fw={500} leftSection={
|
}} size={25} color={colors.chevrons.active}></IconPaperclip>
|
||||||
<IconFile size={14}></IconFile>
|
</Menu.Target>
|
||||||
} onClick={onClickPaperclip}>File</Menu.Item>
|
<Menu.Dropdown style={{
|
||||||
{((avatars.length > 0 && !hasGroup(dialog))
|
userSelect: 'none'
|
||||||
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
|
}}>
|
||||||
&& <Menu.Item fz={'xs'} fw={500} leftSection={
|
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
|
||||||
<IconCamera size={14}></IconCamera>
|
<Menu.Item fz={'xs'} fw={500} leftSection={
|
||||||
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
|
<IconFile size={14}></IconFile>
|
||||||
</Menu.Dropdown>
|
} onClick={onClickPaperclip}>File</Menu.Item>
|
||||||
</Menu>
|
{((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>
|
||||||
<Flex
|
<Flex
|
||||||
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||||
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||||
align={'center'}
|
align={'center'}
|
||||||
>
|
>
|
||||||
<RichTextInput
|
{!isRecording && <>
|
||||||
ref={editableDivRef}
|
<RichTextInput
|
||||||
style={{
|
ref={editableDivRef}
|
||||||
border: 0,
|
style={{
|
||||||
minHeight: 45,
|
border: 0,
|
||||||
fontSize: 14,
|
minHeight: 45,
|
||||||
background: 'transparent',
|
fontSize: 14,
|
||||||
width: '100%',
|
background: 'transparent',
|
||||||
paddingLeft: 10,
|
width: '100%',
|
||||||
paddingRight: 10,
|
paddingLeft: 10,
|
||||||
outline: 'none',
|
paddingRight: 10,
|
||||||
paddingTop: 10,
|
outline: 'none',
|
||||||
paddingBottom: 8
|
paddingTop: 10,
|
||||||
}}
|
paddingBottom: 8
|
||||||
placeholder="Type message..."
|
}}
|
||||||
autoFocus
|
placeholder="Type message..."
|
||||||
//ref={textareaRef}
|
autoFocus
|
||||||
//onPaste={onPaste}
|
onKeyDown={handleKeyDown}
|
||||||
//maxLength={2500}
|
onChange={setMessage}
|
||||||
//w={'100%'}
|
onPaste={onPaste}
|
||||||
//h={'100%'}
|
></RichTextInput>
|
||||||
onKeyDown={handleKeyDown}
|
</>}
|
||||||
onChange={setMessage}
|
{isRecording && <>
|
||||||
onPaste={onPaste}
|
<VoiceRecorder duration={duration} waves={waves}></VoiceRecorder>
|
||||||
|
</>}
|
||||||
//dangerouslySetInnerHTML={{__html: message}}
|
|
||||||
></RichTextInput>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
|
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
|
||||||
<Popover withArrow>
|
{!isRecording && <>
|
||||||
<Popover.Target>
|
<Popover withArrow>
|
||||||
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
|
<Popover.Target>
|
||||||
cursor: 'pointer'
|
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
|
||||||
}}></IconMoodSmile>
|
cursor: 'pointer'
|
||||||
</Popover.Target>
|
}}></IconMoodSmile>
|
||||||
<Popover.Dropdown p={0}>
|
</Popover.Target>
|
||||||
<EmojiPicker
|
<Popover.Dropdown p={0}>
|
||||||
onEmojiClick={onEmojiClick}
|
<EmojiPicker
|
||||||
searchDisabled
|
onEmojiClick={onEmojiClick}
|
||||||
skinTonesDisabled
|
searchDisabled
|
||||||
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
|
skinTonesDisabled
|
||||||
/>
|
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
|
||||||
</Popover.Dropdown>
|
/>
|
||||||
</Popover>
|
</Popover.Dropdown>
|
||||||
<IconSend stroke={1.5} color={message.trim() == "" && attachments.length <= 0 ? colors.chevrons.active : colors.brandColor} onClick={send} style={{
|
</Popover>
|
||||||
cursor: 'pointer'
|
</>}
|
||||||
}} size={25}></IconSend>
|
<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>
|
||||||
</Flex>}
|
</Flex>}
|
||||||
{blocked && <Box mih={62} bg={colors.boxColor}>
|
{blocked && <Box mih={62} bg={colors.boxColor}>
|
||||||
|
|||||||
273
app/components/DialogInput/useVoiceMessage.ts
Normal file
273
app/components/DialogInput/useVoiceMessage.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { ErrorBoundaryProvider } from "@/app/providers/ErrorBoundaryProvider/Err
|
|||||||
import { AttachmentError } from "../AttachmentError/AttachmentError";
|
import { AttachmentError } from "../AttachmentError/AttachmentError";
|
||||||
import { MessageAvatar } from "./MessageAvatar";
|
import { MessageAvatar } from "./MessageAvatar";
|
||||||
import { MessageProps } from "../Messages/Message";
|
import { MessageProps } from "../Messages/Message";
|
||||||
|
import { MessageCall } from "./MessageCall";
|
||||||
|
import { MessageVoice } from "./MessageVoice";
|
||||||
|
|
||||||
export interface MessageAttachmentsProps {
|
export interface MessageAttachmentsProps {
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
@@ -51,6 +53,10 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
|
|||||||
return <MessageFile {...attachProps} key={index}></MessageFile>
|
return <MessageFile {...attachProps} key={index}></MessageFile>
|
||||||
case AttachmentType.AVATAR:
|
case AttachmentType.AVATAR:
|
||||||
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
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:
|
default:
|
||||||
return <AttachmentError key={index}></AttachmentError>;
|
return <AttachmentError key={index}></AttachmentError>;
|
||||||
}
|
}
|
||||||
|
|||||||
62
app/components/MessageAttachments/MessageCall.tsx
Normal file
62
app/components/MessageAttachments/MessageCall.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export function MessageImage(props: AttachmentProps) {
|
|||||||
const [blurhashPreview, setBlurhashPreview] = useState("");
|
const [blurhashPreview, setBlurhashPreview] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.info(props.attachment);
|
||||||
console.info("Consturcting image, download status: " + downloadStatus);
|
console.info("Consturcting image, download status: " + downloadStatus);
|
||||||
constructBlob();
|
constructBlob();
|
||||||
constructFromBlurhash();
|
constructFromBlurhash();
|
||||||
@@ -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'}>
|
{(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={{
|
<Box style={{
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
@@ -168,11 +169,20 @@ export function MessageImage(props: AttachmentProps) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
{downloadPercentage > 0 ? (
|
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} color="white"></AnimatedRoundedProgress>
|
||||||
<AnimatedRoundedProgress size={40} value={downloadPercentage} color="white"></AnimatedRoundedProgress>
|
</Box>
|
||||||
) : (
|
)}
|
||||||
<IconArrowDown size={25} color={'white'} />
|
{!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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ReplyedMessage } from "../ReplyedMessage/ReplyedMessage";
|
|||||||
import { IconX } from "@tabler/icons-react";
|
import { IconX } from "@tabler/icons-react";
|
||||||
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
|
import { MessageReply } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||||
|
|
||||||
export function MessageReplyMessages(props: AttachmentProps) {
|
export function MessageReplyMessages(props: AttachmentProps) {
|
||||||
const colors = useRosettaColors();
|
const colors = useRosettaColors();
|
||||||
@@ -12,9 +13,7 @@ export function MessageReplyMessages(props: AttachmentProps) {
|
|||||||
('showAlertInReplyMessages', true);
|
('showAlertInReplyMessages', true);
|
||||||
const [bgInReplyMessages] = useSetting<string>
|
const [bgInReplyMessages] = useSetting<string>
|
||||||
('bgInReplyMessages', '');
|
('bgInReplyMessages', '');
|
||||||
const reply = JSON.parse(props.attachment.blob);
|
const reply = JSON.parse(props.attachment.blob) as MessageReply[];
|
||||||
|
|
||||||
//console.info("Mreply", reply);
|
|
||||||
|
|
||||||
const closeAlert = () => {
|
const closeAlert = () => {
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
@@ -40,8 +39,8 @@ export function MessageReplyMessages(props: AttachmentProps) {
|
|||||||
{reply.length <= 0 &&
|
{reply.length <= 0 &&
|
||||||
<Skeleton h={50} w={'100%'}></Skeleton>
|
<Skeleton h={50} w={'100%'}></Skeleton>
|
||||||
}
|
}
|
||||||
{reply.map((msg, index) => (
|
{reply.map((msg : MessageReply, index) => (
|
||||||
<ReplyedMessage parent={props.parent} chacha_key_plain={props.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
|
<ReplyedMessage parent={props.parent} chacha_key_plain={msg.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
|
||||||
))}
|
))}
|
||||||
{showAlertInReplyMessages && <Alert style={{
|
{showAlertInReplyMessages && <Alert style={{
|
||||||
borderTopLeftRadius: 0,
|
borderTopLeftRadius: 0,
|
||||||
|
|||||||
267
app/components/MessageAttachments/MessageVoice.tsx
Normal file
267
app/components/MessageAttachments/MessageVoice.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -103,7 +103,11 @@ export function Message(props: MessageProps) {
|
|||||||
publicKey: user.publicKey,
|
publicKey: user.publicKey,
|
||||||
message: props.message,
|
message: props.message,
|
||||||
attachments: props.attachments.filter(a => a.type != AttachmentType.MESSAGES),
|
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);
|
const avatars = useAvatars(user.publicKey);
|
||||||
@@ -125,6 +129,9 @@ export function Message(props: MessageProps) {
|
|||||||
if (props.replyed) {
|
if (props.replyed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if(props.chacha_key_plain == ""){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) {
|
if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export function TextParser(props: TextParserProps) {
|
|||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
let entityCount = 0;
|
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[] = [
|
const formatRules : FormatRule[] = [
|
||||||
{
|
{
|
||||||
pattern: [
|
pattern: [
|
||||||
@@ -119,6 +133,23 @@ export function TextParser(props: TextParserProps) {
|
|||||||
return <>{match}</>;
|
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:
|
// :emoji_code:
|
||||||
pattern: [/:emoji_([a-zA-Z0-9_-]+):/],
|
pattern: [/:emoji_([a-zA-Z0-9_-]+):/],
|
||||||
|
|||||||
278
app/components/VoiceRecorder/VoiceRecorder.tsx
Normal file
278
app/components/VoiceRecorder/VoiceRecorder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,5 +57,7 @@ export const ALLOWED_DOMAINS_ZONES = [
|
|||||||
'fm',
|
'fm',
|
||||||
'tv',
|
'tv',
|
||||||
'im',
|
'im',
|
||||||
'sc'
|
'sc',
|
||||||
|
'su',
|
||||||
|
'by'
|
||||||
];
|
];
|
||||||
78
app/hooks/useSound.ts
Normal file
78
app/hooks/useSound.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
export function useSound() {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const loopingAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const stopSound = () => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
audioRef.current.removeAttribute("src");
|
||||||
|
audioRef.current.load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const playSound = async (sound : string, loop: boolean = false) => {
|
||||||
|
try {
|
||||||
|
if(loop){
|
||||||
|
if (!loopingAudioRef.current) {
|
||||||
|
loopingAudioRef.current = new Audio();
|
||||||
|
loopingAudioRef.current.volume = 0.1;
|
||||||
|
loopingAudioRef.current.preload = "auto";
|
||||||
|
loopingAudioRef.current.loop = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await window.mediaApi.getSoundUrl(sound);
|
||||||
|
const player = loopingAudioRef.current;
|
||||||
|
|
||||||
|
player.src = url;
|
||||||
|
const playPromise = player.play();
|
||||||
|
if (playPromise) {
|
||||||
|
void playPromise.catch((e) => {
|
||||||
|
console.error("Failed to play looping UI sound:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!audioRef.current) {
|
||||||
|
audioRef.current = new Audio();
|
||||||
|
audioRef.current.volume = 0.1;
|
||||||
|
audioRef.current.preload = "auto";
|
||||||
|
audioRef.current.loop = loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await window.mediaApi.getSoundUrl(sound);
|
||||||
|
const player = audioRef.current;
|
||||||
|
|
||||||
|
stopSound();
|
||||||
|
|
||||||
|
player.src = url;
|
||||||
|
const playPromise = player.play();
|
||||||
|
if (playPromise) {
|
||||||
|
void playPromise.catch((e) => {
|
||||||
|
console.error("Failed to play UI sound:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to prepare UI sound:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopLoopSound = () => {
|
||||||
|
if (!loopingAudioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loopingAudioRef.current.pause();
|
||||||
|
loopingAudioRef.current.currentTime = 0;
|
||||||
|
loopingAudioRef.current.removeAttribute("src");
|
||||||
|
loopingAudioRef.current.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
playSound,
|
||||||
|
stopSound,
|
||||||
|
stopLoopSound
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +20,19 @@ const useWindow = () => {
|
|||||||
window.api.send('window-theme', theme);
|
window.api.send('window-theme', theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setWindowPriority = (isTop: boolean) => {
|
||||||
|
if(isTop){
|
||||||
|
window.api.invoke('window-top');
|
||||||
|
} else {
|
||||||
|
window.api.invoke('window-priority-normal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setSize,
|
setSize,
|
||||||
setResizeble,
|
setResizeble,
|
||||||
setTheme
|
setTheme,
|
||||||
|
setWindowPriority
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,10 @@ export enum DownloadStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAttachment(attachment: Attachment, parentMessage: MessageProps) {
|
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 uploadedPercentage = useUploadStatus(attachment.id);
|
||||||
const downloadPercentage = useDownloadStatus(attachment.id);
|
const downloadPercentage = useDownloadStatus(attachment.id);
|
||||||
const [downloadStatus, setDownloadStatus] = useMemory("attachment-downloaded-status-" + attachment.id, DownloadStatus.PENDING, true);
|
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 {readFile, writeFile, fileExists, size} = useFileStorage();
|
||||||
const { downloadFile } = useTransport();
|
const { downloadFile } = useTransport();
|
||||||
const publicKey = usePublicKey();
|
const publicKey = usePublicKey();
|
||||||
@@ -50,36 +49,26 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getPreview = () => {
|
const getPreview = () => {
|
||||||
if(attachment.preview.split("::")[0].match(uuidRegex)){
|
|
||||||
/**
|
|
||||||
* Это тег загрузки
|
|
||||||
*/
|
|
||||||
return attachment.preview.split("::").splice(1).join("::");
|
|
||||||
}
|
|
||||||
return attachment.preview;
|
return attachment.preview;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calcDownloadStatus = async () => {
|
const calcDownloadStatus = async () => {
|
||||||
if(attachment.preview.split("::")[0].match(uuidRegex)){
|
console.info("ds", attachment);
|
||||||
/**
|
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||||
* Это тег загрузки
|
|
||||||
*/
|
|
||||||
setDownloadTag(attachment.preview.split("::")[0]);
|
|
||||||
}
|
|
||||||
if(!attachment.preview.split("::")[0].match(uuidRegex)){
|
|
||||||
/**
|
|
||||||
* Там не тег загрузки, значит это наш файл
|
|
||||||
*/
|
|
||||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
if(attachment.transport.transport_tag == ""){
|
||||||
|
/**
|
||||||
|
* Транспортного тега нет только у сообщений отправленных нами, значит он точно наш
|
||||||
|
*/
|
||||||
|
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(attachment.type == AttachmentType.FILE){
|
if(attachment.type == AttachmentType.FILE){
|
||||||
/**
|
/**
|
||||||
* Если это файл, то он хранится не в папке медиа,
|
* Если это файл, то он хранится не в папке медиа,
|
||||||
* а в загрузках
|
* а в загрузках, статус скачивания определяем не только по названию файла,
|
||||||
|
* но и по его размеру (если размеры и название совпало, то считаем файл скаченным)
|
||||||
*/
|
*/
|
||||||
const preview = getPreview();
|
const preview = getPreview();
|
||||||
const filesize = parseInt(preview.split("::")[0]);
|
const filesize = parseInt(preview.split("::")[0]);
|
||||||
@@ -89,6 +78,9 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
|||||||
const exists = await fileExists(pathInDownloads, false);
|
const exists = await fileExists(pathInDownloads, false);
|
||||||
const existsLength = await size(pathInDownloads, false);
|
const existsLength = await size(pathInDownloads, false);
|
||||||
if(exists && existsLength == filesize){
|
if(exists && existsLength == filesize){
|
||||||
|
/**
|
||||||
|
* Если название файла и его размер совпадают (и он существует), то считаем его скаченным
|
||||||
|
*/
|
||||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,7 +135,7 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
|||||||
let downloadedBlob = '';
|
let downloadedBlob = '';
|
||||||
try {
|
try {
|
||||||
downloadedBlob = await downloadFile(attachment.id,
|
downloadedBlob = await downloadFile(attachment.id,
|
||||||
downloadTag);
|
downloadTag, attachment.transport.transport_server);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.info(e);
|
console.info(e);
|
||||||
info("Error downloading attachment: " + attachment.id);
|
info("Error downloading attachment: " + attachment.id);
|
||||||
@@ -151,6 +143,7 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDownloadStatus(DownloadStatus.DECRYPTING);
|
setDownloadStatus(DownloadStatus.DECRYPTING);
|
||||||
|
console.info("decoding with key " + parentMessage.chacha_key_plain);
|
||||||
//console.info("Decrypted attachment ", Buffer.from(keyPlain, 'binary').toString('hex'));
|
//console.info("Decrypted attachment ", Buffer.from(keyPlain, 'binary').toString('hex'));
|
||||||
const decrypted = await decodeWithPassword(parentMessage.chacha_key_plain, downloadedBlob);
|
const decrypted = await decodeWithPassword(parentMessage.chacha_key_plain, downloadedBlob);
|
||||||
setDownloadTag("");
|
setDownloadTag("");
|
||||||
|
|||||||
226
app/providers/AttachmentProvider/usePrepareAttachment.ts
Normal file
226
app/providers/AttachmentProvider/usePrepareAttachment.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
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} = 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);
|
||||||
|
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
|
||||||
|
if(intervalsRef.current != null){
|
||||||
|
clearInterval(intervalsRef.current);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prepareAttachmentsToSend
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,19 @@ import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
|||||||
import { createContext, useEffect, useRef, useState } from "react";
|
import { createContext, useEffect, useRef, useState } from "react";
|
||||||
import nacl from 'tweetnacl';
|
import nacl from 'tweetnacl';
|
||||||
import { useSender } from "../ProtocolProvider/useSender";
|
import { useSender } from "../ProtocolProvider/useSender";
|
||||||
import { PacketSignal, SignalType } from "../ProtocolProvider/protocol/packets/packet.signal";
|
import { PacketSignalPeer, SignalType } from "../ProtocolProvider/protocol/packets/packet.signal.peer";
|
||||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||||
|
import { PacketWebRTC, WebRTCSignalType } from "../ProtocolProvider/protocol/packets/packet.webrtc";
|
||||||
|
import { PacketIceServers } from "../ProtocolProvider/protocol/packets/packet.ice.servers";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
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 {
|
export interface CallContextValue {
|
||||||
call: (callable: string) => void;
|
call: (callable: string) => void;
|
||||||
@@ -55,8 +64,9 @@ export interface CallProviderProps {
|
|||||||
export function CallProvider(props : CallProviderProps) {
|
export function CallProvider(props : CallProviderProps) {
|
||||||
const [activeCall, setActiveCall] = useState<string>("");
|
const [activeCall, setActiveCall] = useState<string>("");
|
||||||
const [callState, setCallState] = useState<CallState>(CallState.ENDED);
|
const [callState, setCallState] = useState<CallState>(CallState.ENDED);
|
||||||
const [muted, setMuted] = useState<boolean>(false);
|
const [muted, setMutedState] = useState<boolean>(false);
|
||||||
const [sound, setSound] = useState<boolean>(true);
|
const [sound, setSoundState] = useState<boolean>(true);
|
||||||
|
const durationIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [duration, setDuration] = useState<number>(0);
|
const [duration, setDuration] = useState<number>(0);
|
||||||
const [showCallView, setShowCallView] = useState<boolean>(callState == CallState.INCOMING);
|
const [showCallView, setShowCallView] = useState<boolean>(callState == CallState.INCOMING);
|
||||||
const {info} = useConsoleLogger("CallProvider");
|
const {info} = useConsoleLogger("CallProvider");
|
||||||
@@ -64,79 +74,185 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
const send = useSender();
|
const send = useSender();
|
||||||
const publicKey = usePublicKey();
|
const publicKey = usePublicKey();
|
||||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||||
|
const roomIdRef = useRef<string>("");
|
||||||
|
|
||||||
const roleRef = useRef<CallRole | null>(null);
|
const roleRef = useRef<CallRole | null>(null);
|
||||||
const [sharedSecret, setSharedSecret] = useState<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();
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.info("TRACE -> ", sharedSecret)
|
if(callState == CallState.ACTIVE){
|
||||||
}, [sharedSecret]);
|
stopLoopSound();
|
||||||
|
stopSound();
|
||||||
|
playSound("connected.mp3");
|
||||||
|
setWindowPriority(false);
|
||||||
|
durationIntervalRef.current = setInterval(() => {
|
||||||
|
setDuration(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, [callState]);
|
||||||
|
|
||||||
usePacket(26, (packet: PacketSignal) => {
|
useEffect(() => {
|
||||||
const signalType = packet.getSignalType();
|
/**
|
||||||
if(activeCall){
|
* Нам нужно получить ICE серверы для установки соединения из разных сетей
|
||||||
|
* Получаем их от сервера
|
||||||
|
*/
|
||||||
|
let packet = new PacketIceServers();
|
||||||
|
send(packet);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopSound();
|
||||||
|
if (remoteAudioRef.current) {
|
||||||
|
remoteAudioRef.current.pause();
|
||||||
|
remoteAudioRef.current.srcObject = null;
|
||||||
|
}
|
||||||
|
peerConnectionRef.current?.close();
|
||||||
|
peerConnectionRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
usePacket(28, async (packet: PacketIceServers) => {
|
||||||
|
let iceServers = packet.getIceServers();
|
||||||
|
/**
|
||||||
|
* ICE серверы получены, теперь нужно привести их к форматку клиента и добавить udp и tcp варианты
|
||||||
|
*/
|
||||||
|
let formattedIceServers: RTCIceServer[] = [];
|
||||||
|
for(let i = 0; i < iceServers.length; i++){
|
||||||
|
let server = iceServers[i];
|
||||||
|
formattedIceServers.push({
|
||||||
|
urls: "turn:" + server.url + "?transport=" + server.transport,
|
||||||
|
username: server.username,
|
||||||
|
credential: server.credential
|
||||||
|
});
|
||||||
|
}
|
||||||
|
iceServersRef.current = formattedIceServers;
|
||||||
|
info("Received ICE servers from server, count: " + formattedIceServers.length);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
usePacket(27, async (packet: PacketWebRTC) => {
|
||||||
|
if(callState != CallState.WEB_RTC_EXCHANGE && callState != CallState.ACTIVE){
|
||||||
/**
|
/**
|
||||||
* У нас уже есть активный звонок, игнорируем все сигналы, кроме сигналов от текущего звонка
|
* Нет активного звонка или мы не на стадии обмена WebRTC сигналами, игнорируем
|
||||||
*/
|
*/
|
||||||
if(packet.getSrc() != activeCall){
|
return;
|
||||||
console.info("Received signal from " + packet.getSrc() + " but active call is with " + activeCall + ", ignoring");
|
}
|
||||||
info("Received signal for another call, ignoring");
|
const signalType = packet.getSignalType();
|
||||||
|
if(signalType == WebRTCSignalType.ANSWER){
|
||||||
|
/**
|
||||||
|
* Другая сторона (сервер SFU) отправил нам SDP ответ на наш оффер
|
||||||
|
*/
|
||||||
|
const sdp = JSON.parse(packet.getSdpOrCandidate());
|
||||||
|
await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||||
|
if(iceCandidatesBufferRef.current.length > 0){
|
||||||
|
/**
|
||||||
|
* У нас есть буферизированные ICE кандидаты, которые мы получили до установки удаленного описания, теперь мы можем их добавить в PeerConnection
|
||||||
|
*/
|
||||||
|
for(let i = 0; i < iceCandidatesBufferRef.current.length; i++){
|
||||||
|
await peerConnectionRef.current?.addIceCandidate(iceCandidatesBufferRef.current[i]);
|
||||||
|
}
|
||||||
|
iceCandidatesBufferRef.current = [];
|
||||||
|
}
|
||||||
|
info("Received WebRTC answer and set remote description");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(signalType == WebRTCSignalType.ICE_CANDIDATE){
|
||||||
|
/**
|
||||||
|
* Другая сторона отправила нам ICE кандидата для установления WebRTC соединения
|
||||||
|
*/
|
||||||
|
const candidate = JSON.parse(packet.getSdpOrCandidate());
|
||||||
|
if(peerConnectionRef.current?.remoteDescription == null){
|
||||||
|
/**
|
||||||
|
* Удаленное описание еще не установлено, буферизуем кандидата, чтобы добавить его после установки удаленного описания
|
||||||
|
*/
|
||||||
|
iceCandidatesBufferRef.current.push(new RTCIceCandidate(candidate));
|
||||||
|
info("Received WebRTC ICE candidate but remote description is not set yet, buffering candidate");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await peerConnectionRef.current?.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
info("Received WebRTC ICE candidate and added to peer connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(signalType == WebRTCSignalType.OFFER && peerConnectionRef.current){
|
||||||
|
/**
|
||||||
|
* SFU сервер отправил нам оффер, например при renegotiation, нам нужно его принять и
|
||||||
|
* отправить ответ (ANSWER)
|
||||||
|
*/
|
||||||
|
const sdp = JSON.parse(packet.getSdpOrCandidate());
|
||||||
|
await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||||
|
let answer = await peerConnectionRef.current?.createAnswer();
|
||||||
|
await peerConnectionRef.current?.setLocalDescription(answer);
|
||||||
|
let answerSignal = new PacketWebRTC();
|
||||||
|
answerSignal.setSignalType(WebRTCSignalType.ANSWER);
|
||||||
|
answerSignal.setSdpOrCandidate(JSON.stringify(answer));
|
||||||
|
send(answerSignal);
|
||||||
|
info("Received WebRTC offer, set remote description and sent answer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [activeCall, sessionKeys, callState, roomIdRef]);
|
||||||
|
|
||||||
|
usePacket(26, async (packet: PacketSignalPeer) => {
|
||||||
|
const signalType = packet.getSignalType();
|
||||||
|
if(signalType == SignalType.END_CALL_BECAUSE_BUSY) {
|
||||||
|
openCallsModal("Line is busy, the user is currently on another call. Please try again later.");
|
||||||
|
end();
|
||||||
|
}
|
||||||
|
if(signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED) {
|
||||||
|
openCallsModal("The connection with the user was lost. The call has ended.")
|
||||||
|
end();
|
||||||
|
}
|
||||||
|
if(signalType == SignalType.RINGING_TIMEOUT) {
|
||||||
|
/**
|
||||||
|
* Другой стороне был отправлен сигнал звонка, но она не ответила на него в течении определенного времени
|
||||||
|
*/
|
||||||
|
openCallsModal("The user did not answer the call in time. Please try again later.");
|
||||||
|
end();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if(signalType == SignalType.END_CALL){
|
if(signalType == SignalType.END_CALL){
|
||||||
/**
|
/**
|
||||||
* Сбросили звонок
|
* Сбросили звонок
|
||||||
*/
|
*/
|
||||||
setActiveCall("");
|
end();
|
||||||
setCallState(CallState.ENDED);
|
|
||||||
setShowCallView(false);
|
|
||||||
setSessionKeys(null);
|
|
||||||
setSharedSecret("");
|
|
||||||
setDuration(0);
|
|
||||||
roleRef.current = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(signalType == SignalType.CALL){
|
if(signalType == SignalType.CALL){
|
||||||
/**
|
/**
|
||||||
* Нам поступает звонок
|
* Нам поступает звонок
|
||||||
*/
|
*/
|
||||||
|
if(callState != CallState.ENDED){
|
||||||
|
/**
|
||||||
|
* У нас уже есть активный звонок, отправляем сигнал другой стороне, что линия занята
|
||||||
|
*/
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callSessionIdRef.current = packet.getCallId();
|
||||||
|
callTokenRef.current = packet.getJoinToken();
|
||||||
|
setWindowPriority(true);
|
||||||
|
playSound("ringtone.mp3", true);
|
||||||
setActiveCall(packet.getSrc());
|
setActiveCall(packet.getSrc());
|
||||||
setCallState(CallState.INCOMING);
|
setCallState(CallState.INCOMING);
|
||||||
setShowCallView(true);
|
setShowCallView(true);
|
||||||
}
|
}
|
||||||
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLER){
|
if(signalType == SignalType.KEY_EXCHANGE){
|
||||||
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);
|
|
||||||
setSharedSecret(Buffer.from(computedSharedSecret).toString('hex'));
|
|
||||||
info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex'));
|
|
||||||
/**
|
|
||||||
* Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию
|
|
||||||
*/
|
|
||||||
const signalPacket = new PacketSignal();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLEE){
|
|
||||||
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
|
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
|
||||||
/**
|
/**
|
||||||
* Мы отправили свою публичную часть ключа другой стороне,
|
* Другая сторона отправила нам ключи, теперь отправляем ей свои для генерации общего секрета
|
||||||
* теперь мы получили ее публичную часть и можем создать общую
|
|
||||||
* секретную сессию для шифрования звонка
|
|
||||||
*/
|
*/
|
||||||
const sharedPublic = packet.getSharedPublic();
|
const sharedPublic = packet.getSharedPublic();
|
||||||
if(!sharedPublic){
|
if(!sharedPublic){
|
||||||
@@ -147,12 +263,164 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
info("Received key exchange signal but session keys are not generated");
|
info("Received key exchange signal but session keys are not generated");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
||||||
info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex'));
|
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
||||||
setSharedSecret(Buffer.from(computedSharedSecret).toString('hex'));
|
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
||||||
setCallState(CallState.WEB_RTC_EXCHANGE);
|
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);
|
||||||
}
|
}
|
||||||
}, [activeCall, sessionKeys]);
|
if(signalType == SignalType.ACCEPT){
|
||||||
|
/**
|
||||||
|
* Другая сторона приняла наш звонок, комната на SFU создалась, нужно сгенерировать ключи
|
||||||
|
*/
|
||||||
|
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 соединение
|
||||||
|
*/
|
||||||
|
peerConnectionRef.current = new RTCPeerConnection({
|
||||||
|
iceServers: iceServersRef.current,
|
||||||
|
// @ts-ignore
|
||||||
|
encodedInsertableStreams: true
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Подписываемся на ICE кандидат
|
||||||
|
*/
|
||||||
|
peerConnectionRef.current.onicecandidate = (event) => {
|
||||||
|
if(event.candidate){
|
||||||
|
let candidateSignal = new PacketWebRTC();
|
||||||
|
candidateSignal.setSignalType(WebRTCSignalType.ICE_CANDIDATE);
|
||||||
|
candidateSignal.setSdpOrCandidate(JSON.stringify(event.candidate));
|
||||||
|
send(candidateSignal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Соединение установлено, можно начинать звонок, переходим в активное состояние звонка
|
||||||
|
*/
|
||||||
|
peerConnectionRef.current.onconnectionstatechange = () => {
|
||||||
|
console.info("Peer connection state changed: " + peerConnectionRef.current?.connectionState);
|
||||||
|
if(peerConnectionRef.current?.connectionState == "connected"){
|
||||||
|
/**
|
||||||
|
* WebRTC соединение установлено, звонок активен, останавливаем все остальные звуки
|
||||||
|
* системы
|
||||||
|
*/
|
||||||
|
tryActivateCall();
|
||||||
|
info("WebRTC connection established, call is active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peerConnectionRef.current.ontrack = async (event) => {
|
||||||
|
try {
|
||||||
|
await attachReceiverE2EE(event.receiver, Buffer.from(sharedSecretRef.current, "hex"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("attachReceiverE2EE failed:", e);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* При получении медиа-трека с другой стороны
|
||||||
|
*/
|
||||||
|
if(remoteAudioRef.current && event.streams[0]){
|
||||||
|
hasRemoteTrackRef.current = true;
|
||||||
|
tryActivateCall();
|
||||||
|
remoteAudioRef.current.srcObject = event.streams[0];
|
||||||
|
remoteAudioRef.current.muted = !soundRef.current;
|
||||||
|
void remoteAudioRef.current.play().catch((e) => {
|
||||||
|
console.error("Failed to play remote audio:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрашиваем Аудио поток с микрофона и добавляем его в PeerConnection, чтобы другая сторона могла его получить и воспроизвести,
|
||||||
|
* когда мы установим WebRTC соединение
|
||||||
|
*/
|
||||||
|
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const audioTrack = localStream.getAudioTracks()[0];
|
||||||
|
|
||||||
|
|
||||||
|
const tx = peerConnectionRef.current.addTransceiver(audioTrack, {
|
||||||
|
direction: "sendrecv",
|
||||||
|
streams: [localStream]
|
||||||
|
});
|
||||||
|
|
||||||
|
await attachSenderE2EE(tx.sender, Buffer.from(sharedSecretRef.current, "hex"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляем свой оффер другой стороне
|
||||||
|
*/
|
||||||
|
let offer = await peerConnectionRef.current.createOffer();
|
||||||
|
await peerConnectionRef.current.setLocalDescription(offer);
|
||||||
|
let offerSignal = new PacketWebRTC();
|
||||||
|
offerSignal.setSignalType(WebRTCSignalType.OFFER);
|
||||||
|
offerSignal.setSdpOrCandidate(JSON.stringify(offer));
|
||||||
|
send(offerSignal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [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({
|
||||||
|
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 generateSessionKeys = () => {
|
const generateSessionKeys = () => {
|
||||||
const sessionKeys = nacl.box.keyPair();
|
const sessionKeys = nacl.box.keyPair();
|
||||||
@@ -162,32 +430,91 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const call = (dialog: string) => {
|
const call = (dialog: string) => {
|
||||||
|
if(callState == CallState.ACTIVE
|
||||||
|
|| callState == CallState.CONNECTING
|
||||||
|
|| callState == CallState.KEY_EXCHANGE
|
||||||
|
|| callState == CallState.WEB_RTC_EXCHANGE){
|
||||||
|
openCallsModal("You are already on a call, please end the current call before starting a new one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWindowPriority(false);
|
||||||
setActiveCall(dialog);
|
setActiveCall(dialog);
|
||||||
setCallState(CallState.CONNECTING);
|
setCallState(CallState.CONNECTING);
|
||||||
setShowCallView(true);
|
setShowCallView(true);
|
||||||
const signalPacket = new PacketSignal();
|
const signalPacket = new PacketSignalPeer();
|
||||||
signalPacket.setSrc(publicKey);
|
signalPacket.setSrc(publicKey);
|
||||||
signalPacket.setDst(dialog);
|
signalPacket.setDst(dialog);
|
||||||
signalPacket.setSignalType(SignalType.CALL);
|
signalPacket.setSignalType(SignalType.CALL);
|
||||||
send(signalPacket);
|
send(signalPacket);
|
||||||
roleRef.current = CallRole.CALLER;
|
roleRef.current = CallRole.CALLER;
|
||||||
|
playSound("calling.mp3", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
const packetSignal = new PacketSignal();
|
const packetSignal = new PacketSignalPeer();
|
||||||
packetSignal.setSrc(publicKey);
|
packetSignal.setSrc(publicKey);
|
||||||
packetSignal.setDst(activeCall);
|
packetSignal.setDst(activeCall);
|
||||||
|
packetSignal.setCallId(callSessionIdRef.current);
|
||||||
|
packetSignal.setJoinToken(callTokenRef.current);
|
||||||
packetSignal.setSignalType(SignalType.END_CALL);
|
packetSignal.setSignalType(SignalType.END_CALL);
|
||||||
send(packetSignal);
|
send(packetSignal);
|
||||||
|
end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = () => {
|
||||||
|
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);
|
||||||
|
peerConnectionRef.current?.close();
|
||||||
peerConnectionRef.current = null;
|
peerConnectionRef.current = null;
|
||||||
|
roomIdRef.current = "";
|
||||||
|
mutedRef.current = false;
|
||||||
|
soundRef.current = true;
|
||||||
setActiveCall("");
|
setActiveCall("");
|
||||||
setCallState(CallState.ENDED);
|
setCallState(CallState.ENDED);
|
||||||
setShowCallView(false);
|
setShowCallView(false);
|
||||||
setSessionKeys(null);
|
setSessionKeys(null);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
setMutedState(false);
|
||||||
|
setSoundState(true);
|
||||||
|
stopLoopSound();
|
||||||
|
stopSound();
|
||||||
roleRef.current = null;
|
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 = () => {
|
const accept = () => {
|
||||||
if(callState != CallState.INCOMING){
|
if(callState != CallState.INCOMING){
|
||||||
/**
|
/**
|
||||||
@@ -195,16 +522,26 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
*/
|
*/
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setWindowPriority(false);
|
||||||
|
stopLoopSound();
|
||||||
|
stopSound();
|
||||||
/**
|
/**
|
||||||
* Звонок принят, генерируем ключи для сессии и отправляем их другой стороне для установления защищенного канала связи
|
* Звонок принят, генерируем свой ключ для будующего обмена
|
||||||
*/
|
*/
|
||||||
const keys = generateSessionKeys();
|
generateSessionKeys();
|
||||||
const signalPacket = new PacketSignal();
|
/**
|
||||||
|
* Отправляем сигнал что звонок принят другой стороне, чтобы она могла начать обмен ключами и установку соединения
|
||||||
|
*/
|
||||||
|
const signalPacket = new PacketSignalPeer();
|
||||||
signalPacket.setSrc(publicKey);
|
signalPacket.setSrc(publicKey);
|
||||||
signalPacket.setDst(activeCall);
|
signalPacket.setDst(activeCall);
|
||||||
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
|
signalPacket.setCallId(callSessionIdRef.current);
|
||||||
signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex'));
|
signalPacket.setJoinToken(callTokenRef.current);
|
||||||
|
signalPacket.setSignalType(SignalType.ACCEPT);
|
||||||
send(signalPacket);
|
send(signalPacket);
|
||||||
|
/**
|
||||||
|
* Устанавливаем состояние звонка и стадию обмена ключами
|
||||||
|
*/
|
||||||
setCallState(CallState.KEY_EXCHANGE);
|
setCallState(CallState.KEY_EXCHANGE);
|
||||||
roleRef.current = CallRole.CALLEE;
|
roleRef.current = CallRole.CALLEE;
|
||||||
}
|
}
|
||||||
@@ -215,10 +552,50 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
const getKeyCast = () => {
|
const getKeyCast = () => {
|
||||||
if(!sharedSecret){
|
if(!sharedSecretRef.current){
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return sharedSecret;
|
return sharedSecretRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const setMuted = (nextMuted: boolean) => {
|
||||||
|
if (mutedRef.current === nextMuted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutedRef.current = nextMuted;
|
||||||
|
playSound(nextMuted ? "micro_enable.mp3" : "micro_disable.mp3");
|
||||||
|
|
||||||
|
if(peerConnectionRef.current){
|
||||||
|
peerConnectionRef.current.getSenders().forEach(sender => {
|
||||||
|
if(sender.track?.kind == "audio"){
|
||||||
|
sender.track.enabled = !nextMuted;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setMutedState(nextMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSound = (nextSound: boolean) => {
|
||||||
|
if (soundRef.current === nextSound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
soundRef.current = nextSound;
|
||||||
|
playSound(nextSound ? "sound_enable.mp3" : "sound_disable.mp3");
|
||||||
|
|
||||||
|
if(remoteAudioRef.current){
|
||||||
|
remoteAudioRef.current.muted = !nextSound;
|
||||||
|
if (nextSound) {
|
||||||
|
void remoteAudioRef.current.play().catch((e) => {
|
||||||
|
console.error("Failed to resume remote audio:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSoundState(nextSound);
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
@@ -239,6 +616,7 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
return (
|
return (
|
||||||
<CallContext.Provider value={context}>
|
<CallContext.Provider value={context}>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
<audio ref={remoteAudioRef} autoPlay playsInline style={{ display: 'none' }} />
|
||||||
{showCallView && <Call context={context}></Call>}
|
{showCallView && <Call context={context}></Call>}
|
||||||
</CallContext.Provider>
|
</CallContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
114
app/providers/CallProvider/audioE2EE.ts
Normal file
114
app/providers/CallProvider/audioE2EE.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import _sodium from "libsodium-wrappers-sumo";
|
||||||
|
|
||||||
|
type KeyInput = Buffer | Uint8Array;
|
||||||
|
|
||||||
|
const senderAttached = new WeakSet<RTCRtpSender>();
|
||||||
|
const receiverAttached = new WeakSet<RTCRtpReceiver>();
|
||||||
|
|
||||||
|
let sodiumReady = false;
|
||||||
|
let sodium: typeof _sodium;
|
||||||
|
|
||||||
|
export async function initE2EE(): Promise<void> {
|
||||||
|
if (sodiumReady) return;
|
||||||
|
await _sodium.ready;
|
||||||
|
sodium = _sodium;
|
||||||
|
sodiumReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
if (senderAttached.has(sender)) return;
|
||||||
|
senderAttached.add(sender);
|
||||||
|
|
||||||
|
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 not available on RTCRtpSender");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { readable, writable } = anySender.createEncodedStreams();
|
||||||
|
const processFrame = createFrameProcessor(key.slice(0, keyLen));
|
||||||
|
|
||||||
|
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> {
|
||||||
|
if (receiverAttached.has(receiver)) return;
|
||||||
|
receiverAttached.add(receiver);
|
||||||
|
|
||||||
|
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 not available on RTCRtpReceiver");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { readable, writable } = anyReceiver.createEncodedStreams();
|
||||||
|
const processFrame = createFrameProcessor(key.slice(0, keyLen));
|
||||||
|
|
||||||
|
readable
|
||||||
|
.pipeThrough(createTransform(processFrame))
|
||||||
|
.pipeTo(writable)
|
||||||
|
.catch((e) => console.error("[E2EE] Receiver pipeline failed:", e));
|
||||||
|
}
|
||||||
@@ -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 { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
|
||||||
import { createContext, useEffect, useRef, useState } from 'react';
|
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 { usePrivatePlain } from '../AccountProvider/usePrivatePlain';
|
||||||
import { usePublicKey } from '../AccountProvider/usePublicKey';
|
import { usePublicKey } from '../AccountProvider/usePublicKey';
|
||||||
import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read';
|
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 { useLogger } from '@/app/hooks/useLogger';
|
||||||
import { useSender } from '../ProtocolProvider/useSender';
|
import { useSender } from '../ProtocolProvider/useSender';
|
||||||
import { usePacket } from '../ProtocolProvider/usePacket';
|
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 { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery';
|
||||||
import { useIdle } from '@mantine/hooks';
|
import { useIdle } from '@mantine/hooks';
|
||||||
import { useWindowFocus } from '@/app/hooks/useWindowFocus';
|
import { useWindowFocus } from '@/app/hooks/useWindowFocus';
|
||||||
import { useDialogsCache } from './useDialogsCache';
|
import { useDialogsCache } from './useDialogsCache';
|
||||||
import { useConsoleLogger } from '@/app/hooks/useConsoleLogger';
|
import { useConsoleLogger } from '@/app/hooks/useConsoleLogger';
|
||||||
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
|
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
|
||||||
import { MessageReply } from './useReplyMessages';
|
|
||||||
import { useTransport } from '../TransportProvider/useTransport';
|
|
||||||
import { useFileStorage } from '@/app/hooks/useFileStorage';
|
import { useFileStorage } from '@/app/hooks/useFileStorage';
|
||||||
import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
|
import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
|
||||||
import { useDialogsList } from '../DialogListProvider/useDialogsList';
|
import { useDialogsList } from '../DialogListProvider/useDialogsList';
|
||||||
import { useGroups } from './useGroups';
|
import { useGroups } from './useGroups';
|
||||||
import { useMentions } from '../DialogStateProvider.tsx/useMentions';
|
import { useMentions } from '../DialogStateProvider.tsx/useMentions';
|
||||||
import { base64ImageToBlurhash } from '@/app/workers/image/image';
|
|
||||||
|
|
||||||
export interface DialogContextValue {
|
export interface DialogContextValue {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -33,7 +30,6 @@ export interface DialogContextValue {
|
|||||||
setMessages: (messages: React.SetStateAction<Message[]>) => void;
|
setMessages: (messages: React.SetStateAction<Message[]>) => void;
|
||||||
dialog: string;
|
dialog: string;
|
||||||
clearDialogCache: () => void;
|
clearDialogCache: () => void;
|
||||||
prepareAttachmentsToSend: (password: string, attachments: Attachment[]) => Promise<Attachment[]>;
|
|
||||||
loadMessagesToTop: () => Promise<void>;
|
loadMessagesToTop: () => Promise<void>;
|
||||||
loadMessagesToMessageId: (messageId: string) => Promise<void>;
|
loadMessagesToMessageId: (messageId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -50,6 +46,7 @@ export interface AttachmentMeta {
|
|||||||
id: string;
|
id: string;
|
||||||
type: AttachmentType;
|
type: AttachmentType;
|
||||||
preview: string;
|
preview: string;
|
||||||
|
transport: AttachmentTransport;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -71,6 +68,23 @@ interface DialogProviderProps {
|
|||||||
dialog: string;
|
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) {
|
export function DialogProvider(props: DialogProviderProps) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const {allQuery, runQuery} = useDatabase();
|
const {allQuery, runQuery} = useDatabase();
|
||||||
@@ -88,15 +102,21 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache();
|
const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache();
|
||||||
const {info, warn, error} = useConsoleLogger('DialogProvider');
|
const {info, warn, error} = useConsoleLogger('DialogProvider');
|
||||||
const [viewState] = useViewPanelsState();
|
const [viewState] = useViewPanelsState();
|
||||||
const {uploadFile} = useTransport();
|
|
||||||
const {readFile} = useFileStorage();
|
const {readFile} = useFileStorage();
|
||||||
const intervalsRef = useRef<NodeJS.Timeout>(null);
|
|
||||||
const systemAccounts = useSystemAccounts();
|
const systemAccounts = useSystemAccounts();
|
||||||
const {updateDialog} = useDialogsList();
|
const {updateDialog} = useDialogsList();
|
||||||
const {hasGroup, getGroupKey} = useGroups();
|
const {hasGroup, getGroupKey} = useGroups();
|
||||||
const {popMention, isMentioned} = useMentions();
|
const {popMention, isMentioned} = useMentions();
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = onDialogMessage(({ dialogId, message }) => {
|
||||||
|
if (dialogId !== props.dialog) return;
|
||||||
|
setMessages((prev) => [...prev, message]);
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [props.dialog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentDialogPublicKeyView(props.dialog);
|
setCurrentDialogPublicKeyView(props.dialog);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -195,26 +215,40 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
readUpdated = true;
|
readUpdated = true;
|
||||||
}
|
}
|
||||||
let decryptKey = '';
|
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:")){
|
if(message.from_me && message.chacha_key != "" && message.chacha_key.startsWith("sync:")){
|
||||||
/**
|
/**
|
||||||
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это
|
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это
|
||||||
* сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет,
|
* сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет,
|
||||||
* значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (plain_message)
|
* значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (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)){
|
if(hasGroup(props.dialog)){
|
||||||
/**
|
/**
|
||||||
* Если это групповое сообщение, то получаем ключ группы
|
* Если это групповое сообщение, то получаем ключ группы
|
||||||
*/
|
*/
|
||||||
decryptKey = await getGroupKey(props.dialog);
|
decryptKey = await getGroupKey(props.dialog);
|
||||||
|
/**
|
||||||
|
* Приводим к HEX так как этого требует формат расшифровки вложений в приложении
|
||||||
|
*/
|
||||||
|
decryptKey = Buffer.from(decryptKey).toString('hex');
|
||||||
}
|
}
|
||||||
if(!message.from_me && !hasGroup(props.dialog)){
|
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({
|
finalMessages.push({
|
||||||
from_public_key: message.from_public_key,
|
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++) {
|
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
attachments.push({
|
attachments.push({
|
||||||
id: attachment.id,
|
...attachment,
|
||||||
preview: attachment.preview,
|
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
|
||||||
type: attachment.type,
|
|
||||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +495,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: 0, //сообщение прочитано
|
readed: 0, //сообщение прочитано
|
||||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||||
from_me: 1, //сообщение от нас
|
from_me: 1, //сообщение от нас
|
||||||
plain_message: (decryptedContent as string),
|
plain_message: (decryptedContent as string),
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -530,9 +562,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
attachments.push({
|
attachments.push({
|
||||||
id: attachment.id,
|
...attachment,
|
||||||
preview: attachment.preview,
|
|
||||||
type: attachment.type,
|
|
||||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -543,7 +573,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: 0,
|
readed: 0,
|
||||||
chacha_key: groupKey,
|
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||||
from_me: 1,
|
from_me: 1,
|
||||||
plain_message: decryptedContent,
|
plain_message: decryptedContent,
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -608,20 +638,18 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
attachments.push({
|
attachments.push({
|
||||||
id: attachment.id,
|
...attachment,
|
||||||
preview: attachment.preview,
|
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
|
||||||
type: attachment.type,
|
|
||||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
console.info(attachments);
|
||||||
const newMessage : Message = {
|
const newMessage : Message = {
|
||||||
from_public_key: fromPublicKey,
|
from_public_key: fromPublicKey,
|
||||||
to_public_key: toPublicKey,
|
to_public_key: toPublicKey,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: (decryptedContent as string),
|
plain_message: (decryptedContent as string),
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -688,9 +716,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
attachments.push({
|
attachments.push({
|
||||||
id: attachment.id,
|
...attachment,
|
||||||
preview: attachment.preview,
|
|
||||||
type: attachment.type,
|
|
||||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -701,7 +727,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: groupKey,
|
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: decryptedContent,
|
plain_message: decryptedContent,
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
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({
|
finalMessages.push({
|
||||||
from_public_key: message.from_public_key,
|
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({
|
finalMessages.push({
|
||||||
from_public_key: message.from_public_key,
|
from_public_key: message.from_public_key,
|
||||||
@@ -919,6 +945,16 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if(meta.type == AttachmentType.CALL){
|
||||||
|
/**
|
||||||
|
* Если это звонок
|
||||||
|
*/
|
||||||
|
attachments.push({
|
||||||
|
...meta,
|
||||||
|
blob: ""
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`);
|
const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`);
|
||||||
if(!fileData) {
|
if(!fileData) {
|
||||||
attachments.push({
|
attachments.push({
|
||||||
@@ -935,115 +971,18 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
id: meta.id,
|
id: meta.id,
|
||||||
blob: blob,
|
blob: blob,
|
||||||
type: meta.type,
|
type: meta.type,
|
||||||
preview: meta.preview
|
preview: meta.preview,
|
||||||
|
transport: meta.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return attachments;
|
return attachments;
|
||||||
}catch(e) {
|
}catch(e) {
|
||||||
|
console.info(e);
|
||||||
error("Failed to parse attachments");
|
error("Failed to parse attachments");
|
||||||
}
|
}
|
||||||
return [];
|
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, так как может возникать ситуация, что одно и то же сообщение
|
* Дедубликация сообщений по message_id, так как может возникать ситуация, что одно и то же сообщение
|
||||||
* может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации
|
* может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации
|
||||||
@@ -1071,7 +1010,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog));
|
setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog));
|
||||||
},
|
},
|
||||||
dialog: props.dialog,
|
dialog: props.dialog,
|
||||||
prepareAttachmentsToSend,
|
|
||||||
loadMessagesToTop,
|
loadMessagesToTop,
|
||||||
loadMessagesToMessageId
|
loadMessagesToMessageId
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
161
app/providers/DialogProvider/useDeattachedSender.ts
Normal file
161
app/providers/DialogProvider/useDeattachedSender.ts
Normal 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};
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
|||||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||||
import { useGroups } from "./useGroups";
|
import { useGroups } from "./useGroups";
|
||||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||||
|
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
|
||||||
|
|
||||||
export function useDialog() : {
|
export function useDialog() : {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
@@ -35,7 +36,6 @@ export function useDialog() : {
|
|||||||
}
|
}
|
||||||
const {loading,
|
const {loading,
|
||||||
messages,
|
messages,
|
||||||
prepareAttachmentsToSend,
|
|
||||||
clearDialogCache,
|
clearDialogCache,
|
||||||
setMessages,
|
setMessages,
|
||||||
dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
|
dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
|
||||||
@@ -47,6 +47,7 @@ export function useDialog() : {
|
|||||||
const [protocolState] = useProtocolState();
|
const [protocolState] = useProtocolState();
|
||||||
const {hasGroup, getGroupKey} = useGroups();
|
const {hasGroup, getGroupKey} = useGroups();
|
||||||
const {warn} = useConsoleLogger('useDialog');
|
const {warn} = useConsoleLogger('useDialog');
|
||||||
|
const {prepareAttachmentsToSend} = usePrepareAttachment();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправка сообщения в диалог
|
* Отправка сообщения в диалог
|
||||||
@@ -95,14 +96,13 @@ export function useDialog() : {
|
|||||||
* же сообщений (смотреть problem_sync.md)
|
* же сообщений (смотреть problem_sync.md)
|
||||||
*/
|
*/
|
||||||
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
|
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
|
||||||
|
|
||||||
setMessages((prev : Message[]) => ([...prev, {
|
setMessages((prev : Message[]) => ([...prev, {
|
||||||
from_public_key: publicKey,
|
from_public_key: publicKey,
|
||||||
to_public_key: dialog,
|
to_public_key: dialog,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
readed: publicKey == dialog ? 1 : 0,
|
readed: publicKey == dialog ? 1 : 0,
|
||||||
chacha_key: "",
|
chacha_key: key.toString('hex'),
|
||||||
from_me: 1,
|
from_me: 1,
|
||||||
plain_message: message,
|
plain_message: message,
|
||||||
delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
|
delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
|
||||||
@@ -117,7 +117,8 @@ export function useDialog() : {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
if(attachment.type == AttachmentType.FILE){
|
if(attachment.type == AttachmentType.FILE){
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +135,7 @@ export function useDialog() : {
|
|||||||
await runQuery(`
|
await runQuery(`
|
||||||
INSERT INTO messages
|
INSERT INTO messages
|
||||||
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
(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
|
protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING
|
||||||
), JSON.stringify(attachmentsMeta)]);
|
), JSON.stringify(attachmentsMeta)]);
|
||||||
updateDialog(dialog);
|
updateDialog(dialog);
|
||||||
@@ -144,9 +145,9 @@ export function useDialog() : {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
|
|
||||||
console.info("Sending key for message ", key.toString('hex'));
|
console.info("Sending key for message ", key.toString('hex'));
|
||||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(key.toString('utf-8'), attachemnts);
|
console.info(attachemnts);
|
||||||
|
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
|
||||||
if(attachemnts.length <= 0 && message.trim() == ""){
|
if(attachemnts.length <= 0 && message.trim() == ""){
|
||||||
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
||||||
updateDialog(dialog);
|
updateDialog(dialog);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
|||||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||||
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
|
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 { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
|
||||||
import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery";
|
import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery";
|
||||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||||
@@ -104,7 +104,7 @@ export function useDialogFiber() {
|
|||||||
decryptedContent = '';
|
decryptedContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let attachmentsMeta: any[] = [];
|
let attachmentsMeta: AttachmentMeta[] = [];
|
||||||
let messageAttachments: Attachment[] = [];
|
let messageAttachments: Attachment[] = [];
|
||||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
@@ -129,7 +129,8 @@ export function useDialogFiber() {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +140,7 @@ export function useDialogFiber() {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: groupKey,
|
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: decryptedContent,
|
plain_message: decryptedContent,
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -261,7 +262,7 @@ export function useDialogFiber() {
|
|||||||
const nonce = chachaDecryptedKey.slice(32);
|
const nonce = chachaDecryptedKey.slice(32);
|
||||||
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
||||||
|
|
||||||
let attachmentsMeta: any[] = [];
|
let attachmentsMeta: AttachmentMeta[] = [];
|
||||||
let messageAttachments: Attachment[] = [];
|
let messageAttachments: Attachment[] = [];
|
||||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
@@ -277,7 +278,7 @@ export function useDialogFiber() {
|
|||||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
* Этот тип вложения приходит сразу в 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)}`,
|
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||||
@@ -286,7 +287,8 @@ export function useDialogFiber() {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +298,7 @@ export function useDialogFiber() {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: (decryptedContent as string),
|
plain_message: (decryptedContent as string),
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function useDialogsCache() {
|
|||||||
|
|
||||||
const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => {
|
const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => {
|
||||||
/**
|
/**
|
||||||
* TODO: Optimize this function to avoid full map if possible
|
* TODO: Оптимизировать чтобы проходил снизу вверх
|
||||||
*/
|
*/
|
||||||
let newCache = dialogsCache.map((cache) => {
|
let newCache = dialogsCache.map((cache) => {
|
||||||
let newMessages = cache.messages.map((message) => {
|
let newMessages = cache.messages.map((message) => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface MessageReply {
|
|||||||
message: string;
|
message: string;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
message_id: string;
|
message_id: string;
|
||||||
|
chacha_key_plain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReplyMessages() {
|
export function useReplyMessages() {
|
||||||
@@ -53,7 +54,6 @@ export function useReplyMessages() {
|
|||||||
}
|
}
|
||||||
replyMessages.messages.push(message);
|
replyMessages.messages.push(message);
|
||||||
const sortedByTime = replyMessages.messages.sort((a, b) => a.timestamp - b.timestamp);
|
const sortedByTime = replyMessages.messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
setReplyMessages({
|
setReplyMessages({
|
||||||
publicKey: dialog,
|
publicKey: dialog,
|
||||||
messages: sortedByTime
|
messages: sortedByTime
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useGroupInviteStatus } from "./useGroupInviteStatus";
|
|||||||
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
|
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||||
import { useUpdateSyncTime } from "./useUpdateSyncTime";
|
import { useUpdateSyncTime } from "./useUpdateSyncTime";
|
||||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
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 { MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from "@/app/constants";
|
||||||
import { useMemory } from "../MemoryProvider/useMemory";
|
import { useMemory } from "../MemoryProvider/useMemory";
|
||||||
import { useDialogsCache } from "./useDialogsCache";
|
import { useDialogsCache } from "./useDialogsCache";
|
||||||
@@ -165,7 +165,7 @@ export function useSynchronize() {
|
|||||||
const nonce = chachaDecryptedKey.slice(32);
|
const nonce = chachaDecryptedKey.slice(32);
|
||||||
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
||||||
await updateSyncTime(timestamp);
|
await updateSyncTime(timestamp);
|
||||||
let attachmentsMeta: any[] = [];
|
let attachmentsMeta: AttachmentMeta[] = [];
|
||||||
let messageAttachments: Attachment[] = [];
|
let messageAttachments: Attachment[] = [];
|
||||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
@@ -181,7 +181,7 @@ export function useSynchronize() {
|
|||||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
* Этот тип вложения приходит сразу в 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)}`,
|
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||||
@@ -190,7 +190,8 @@ export function useSynchronize() {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +201,7 @@ export function useSynchronize() {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: 1, //сообщение прочитано
|
readed: 1, //сообщение прочитано
|
||||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||||
from_me: 1, //сообщение от нас
|
from_me: 1, //сообщение от нас
|
||||||
plain_message: (decryptedContent as string),
|
plain_message: (decryptedContent as string),
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -347,7 +348,7 @@ export function useSynchronize() {
|
|||||||
decryptedContent = '';
|
decryptedContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let attachmentsMeta: any[] = [];
|
let attachmentsMeta: AttachmentMeta[] = [];
|
||||||
let messageAttachments: Attachment[] = [];
|
let messageAttachments: Attachment[] = [];
|
||||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
@@ -372,7 +373,8 @@ export function useSynchronize() {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
300
app/providers/PlayerProvider/PlayerProvider.tsx
Normal file
300
app/providers/PlayerProvider/PlayerProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/providers/PlayerProvider/usePlayerContext.ts
Normal file
10
app/providers/PlayerProvider/usePlayerContext.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Packet from "../packet";
|
||||||
|
import Stream from "../stream";
|
||||||
|
|
||||||
|
export interface G365IceServer {
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
credential: string;
|
||||||
|
transport: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PacketIceServers extends Packet {
|
||||||
|
private iceServers: G365IceServer[] = [];
|
||||||
|
|
||||||
|
public getPacketId(): number {
|
||||||
|
return 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
public _receive(stream: Stream): void {
|
||||||
|
const serversCount = stream.readInt16();
|
||||||
|
this.iceServers = [];
|
||||||
|
for(let i = 0; i < serversCount; i++){
|
||||||
|
const url = stream.readString();
|
||||||
|
const username = stream.readString();
|
||||||
|
const credential = stream.readString();
|
||||||
|
const transport = stream.readString();
|
||||||
|
this.iceServers.push({
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
credential,
|
||||||
|
transport
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public _send(): Promise<Stream> | Stream {
|
||||||
|
const stream = new Stream();
|
||||||
|
stream.writeInt16(this.getPacketId());
|
||||||
|
stream.writeInt16(this.iceServers.length);
|
||||||
|
for(let i = 0; i < this.iceServers.length; i++){
|
||||||
|
const server = this.iceServers[i];
|
||||||
|
stream.writeString(server.url);
|
||||||
|
stream.writeString(server.username || "");
|
||||||
|
stream.writeString(server.credential || "");
|
||||||
|
stream.writeString(server.transport || "");
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getIceServers(): G365IceServer[] {
|
||||||
|
return this.iceServers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setIceServers(servers: G365IceServer[]) {
|
||||||
|
this.iceServers = servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -5,14 +5,26 @@ export enum AttachmentType {
|
|||||||
IMAGE = 0,
|
IMAGE = 0,
|
||||||
MESSAGES = 1,
|
MESSAGES = 1,
|
||||||
FILE = 2,
|
FILE = 2,
|
||||||
AVATAR = 3
|
AVATAR = 3,
|
||||||
|
CALL = 4,
|
||||||
|
VOICE = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Информация о транспортировке вложения, нужна для загрузки и скачивания вложений с транспортного сервера
|
||||||
|
*/
|
||||||
|
export interface AttachmentTransport {
|
||||||
|
transport_tag: string;
|
||||||
|
transport_server: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
blob: string;
|
blob: string;
|
||||||
type: AttachmentType;
|
type: AttachmentType;
|
||||||
preview: string;
|
preview: string;
|
||||||
|
transport: AttachmentTransport;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PacketMessage extends Packet {
|
export class PacketMessage extends Packet {
|
||||||
@@ -41,7 +53,7 @@ export class PacketMessage extends Packet {
|
|||||||
this.toPublicKey = stream.readString();
|
this.toPublicKey = stream.readString();
|
||||||
this.content = stream.readString();
|
this.content = stream.readString();
|
||||||
this.chachaKey = stream.readString();
|
this.chachaKey = stream.readString();
|
||||||
this.timestamp = stream.readInt64();
|
this.timestamp = Number(stream.readInt64());
|
||||||
this.privateKey = stream.readString();
|
this.privateKey = stream.readString();
|
||||||
this.messageId = stream.readString();
|
this.messageId = stream.readString();
|
||||||
let attachmentsCount = stream.readInt8();
|
let attachmentsCount = stream.readInt8();
|
||||||
@@ -50,7 +62,11 @@ export class PacketMessage extends Packet {
|
|||||||
let preview = stream.readString();
|
let preview = stream.readString();
|
||||||
let blob = stream.readString();
|
let blob = stream.readString();
|
||||||
let type = stream.readInt8() as AttachmentType;
|
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();
|
this.aesChachaKey = stream.readString();
|
||||||
}
|
}
|
||||||
@@ -62,7 +78,7 @@ export class PacketMessage extends Packet {
|
|||||||
stream.writeString(this.toPublicKey);
|
stream.writeString(this.toPublicKey);
|
||||||
stream.writeString(this.content);
|
stream.writeString(this.content);
|
||||||
stream.writeString(this.chachaKey);
|
stream.writeString(this.chachaKey);
|
||||||
stream.writeInt64(this.timestamp);
|
stream.writeInt64(BigInt(this.timestamp));
|
||||||
stream.writeString(this.privateKey);
|
stream.writeString(this.privateKey);
|
||||||
stream.writeString(this.messageId);
|
stream.writeString(this.messageId);
|
||||||
stream.writeInt8(this.attachments.length);
|
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].preview);
|
||||||
stream.writeString(this.attachments[i].blob);
|
stream.writeString(this.attachments[i].blob);
|
||||||
stream.writeInt8(this.attachments[i].type);
|
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);
|
stream.writeString(this.aesChachaKey);
|
||||||
return stream;
|
return stream;
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ export enum SignalType {
|
|||||||
CALL = 0,
|
CALL = 0,
|
||||||
KEY_EXCHANGE = 1,
|
KEY_EXCHANGE = 1,
|
||||||
ACTIVE_CALL = 2,
|
ACTIVE_CALL = 2,
|
||||||
END_CALL = 3
|
END_CALL = 3,
|
||||||
|
/**
|
||||||
|
* Переведен в стадию активного, значит комната на SFU уже создана и можно начинать обмен сигналами WebRTC
|
||||||
|
*/
|
||||||
|
ACTIVE = 4,
|
||||||
|
END_CALL_BECAUSE_PEER_DISCONNECTED = 5,
|
||||||
|
END_CALL_BECAUSE_BUSY = 6,
|
||||||
|
ACCEPT = 7,
|
||||||
|
RINGING_TIMEOUT = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пакет сигналинга, для сигналов WebRTC используется отдельный пакет 27 PacketWebRTCExchange
|
* Пакет сигналинга, для сигналов WebRTC используется отдельный пакет 27 PacketWebRTCExchange
|
||||||
*/
|
*/
|
||||||
export class PacketSignal extends Packet {
|
export class PacketSignalPeer extends Packet {
|
||||||
|
|
||||||
private src: string = "";
|
private src: string = "";
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +33,9 @@ export class PacketSignal extends Packet {
|
|||||||
|
|
||||||
private signalType: SignalType = SignalType.CALL;
|
private signalType: SignalType = SignalType.CALL;
|
||||||
|
|
||||||
|
private callId: string = "";
|
||||||
|
private joinToken: string = "";
|
||||||
|
|
||||||
|
|
||||||
public getPacketId(): number {
|
public getPacketId(): number {
|
||||||
return 26;
|
return 26;
|
||||||
@@ -32,22 +43,40 @@ export class PacketSignal extends Packet {
|
|||||||
|
|
||||||
public _receive(stream: Stream): void {
|
public _receive(stream: Stream): void {
|
||||||
this.signalType = stream.readInt8();
|
this.signalType = stream.readInt8();
|
||||||
|
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();
|
this.src = stream.readString();
|
||||||
this.dst = stream.readString();
|
this.dst = stream.readString();
|
||||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||||
this.sharedPublic = stream.readString();
|
this.sharedPublic = stream.readString();
|
||||||
}
|
}
|
||||||
|
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
|
||||||
|
this.callId = stream.readString();
|
||||||
|
this.joinToken = stream.readString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public _send(): Promise<Stream> | Stream {
|
public _send(): Promise<Stream> | Stream {
|
||||||
const stream = new Stream();
|
const stream = new Stream();
|
||||||
stream.writeInt16(this.getPacketId());
|
stream.writeInt16(this.getPacketId());
|
||||||
stream.writeInt8(this.signalType);
|
stream.writeInt8(this.signalType);
|
||||||
|
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);
|
stream.writeString(this.src);
|
||||||
stream.writeString(this.dst);
|
stream.writeString(this.dst);
|
||||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||||
stream.writeString(this.sharedPublic);
|
stream.writeString(this.sharedPublic);
|
||||||
}
|
}
|
||||||
|
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
|
||||||
|
stream.writeString(this.callId);
|
||||||
|
stream.writeString(this.joinToken);
|
||||||
|
}
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,4 +112,20 @@ export class PacketSignal extends Packet {
|
|||||||
this.src = src;
|
this.src = src;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getCallId(): string {
|
||||||
|
return this.callId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setCallId(callId: string) {
|
||||||
|
this.callId = callId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getJoinToken(): string {
|
||||||
|
return this.joinToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setJoinToken(joinToken: string) {
|
||||||
|
this.joinToken = joinToken;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -18,14 +18,14 @@ export class PacketSync extends Packet {
|
|||||||
|
|
||||||
public _receive(stream: Stream): void {
|
public _receive(stream: Stream): void {
|
||||||
this.status = stream.readInt8() as SyncStatus;
|
this.status = stream.readInt8() as SyncStatus;
|
||||||
this.timestamp = stream.readInt64();
|
this.timestamp = Number(stream.readInt64());
|
||||||
}
|
}
|
||||||
|
|
||||||
public _send(): Promise<Stream> | Stream {
|
public _send(): Promise<Stream> | Stream {
|
||||||
let stream = new Stream();
|
let stream = new Stream();
|
||||||
stream.writeInt16(this.getPacketId());
|
stream.writeInt16(this.getPacketId());
|
||||||
stream.writeInt8(this.status);
|
stream.writeInt8(this.status);
|
||||||
stream.writeInt64(this.timestamp);
|
stream.writeInt64(BigInt(this.timestamp));
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Packet from "../packet";
|
||||||
|
import Stream from "../stream";
|
||||||
|
|
||||||
|
export enum WebRTCSignalType {
|
||||||
|
OFFER = 0,
|
||||||
|
ANSWER = 1,
|
||||||
|
ICE_CANDIDATE = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пакет для обмена сигналами WebRTC, такими как оффер, ответ и ICE кандидаты.
|
||||||
|
* Используется на стадии WEB_RTC_EXCHANGE в сигналинге звонков.
|
||||||
|
*/
|
||||||
|
export class PacketWebRTC extends Packet {
|
||||||
|
|
||||||
|
private signalType: WebRTCSignalType = WebRTCSignalType.OFFER;
|
||||||
|
private sdpOrCandidate: string = "";
|
||||||
|
|
||||||
|
public getPacketId(): number {
|
||||||
|
return 27;
|
||||||
|
}
|
||||||
|
|
||||||
|
public _receive(stream: Stream): void {
|
||||||
|
this.signalType = stream.readInt8();
|
||||||
|
this.sdpOrCandidate = stream.readString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public _send(): Promise<Stream> | Stream {
|
||||||
|
let stream = new Stream();
|
||||||
|
stream.writeInt16(this.getPacketId());
|
||||||
|
stream.writeInt8(this.signalType);
|
||||||
|
stream.writeString(this.sdpOrCandidate);
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSignalType(type: WebRTCSignalType) {
|
||||||
|
this.signalType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSignalType(): WebRTCSignalType {
|
||||||
|
return this.signalType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSdpOrCandidate(data: string) {
|
||||||
|
this.sdpOrCandidate = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSdpOrCandidate(): string {
|
||||||
|
return this.sdpOrCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ import { PacketDeviceNew } from "./packets/packet.device.new";
|
|||||||
import { PacketDeviceList } from "./packets/packet.device.list";
|
import { PacketDeviceList } from "./packets/packet.device.list";
|
||||||
import { PacketDeviceResolve } from "./packets/packet.device.resolve";
|
import { PacketDeviceResolve } from "./packets/packet.device.resolve";
|
||||||
import { PacketSync } from "./packets/packet.sync";
|
import { PacketSync } from "./packets/packet.sync";
|
||||||
import { PacketSignal } from "./packets/packet.signal";
|
import { PacketSignalPeer } from "./packets/packet.signal.peer";
|
||||||
|
import { PacketWebRTC } from "./packets/packet.webrtc";
|
||||||
|
import { PacketIceServers } from "./packets/packet.ice.servers";
|
||||||
|
|
||||||
export default class Protocol extends EventEmitter {
|
export default class Protocol extends EventEmitter {
|
||||||
private serverAddress: string;
|
private serverAddress: string;
|
||||||
@@ -126,7 +128,9 @@ export default class Protocol extends EventEmitter {
|
|||||||
this._supportedPackets.set(0x17, new PacketDeviceList());
|
this._supportedPackets.set(0x17, new PacketDeviceList());
|
||||||
this._supportedPackets.set(0x18, new PacketDeviceResolve());
|
this._supportedPackets.set(0x18, new PacketDeviceResolve());
|
||||||
this._supportedPackets.set(25, new PacketSync());
|
this._supportedPackets.set(25, new PacketSync());
|
||||||
this._supportedPackets.set(26, new PacketSignal());
|
this._supportedPackets.set(26, new PacketSignalPeer());
|
||||||
|
this._supportedPackets.set(27, new PacketWebRTC());
|
||||||
|
this._supportedPackets.set(28, new PacketIceServers());
|
||||||
}
|
}
|
||||||
|
|
||||||
private _findWaiters(packetId: number): ((packet: Packet) => void)[] {
|
private _findWaiters(packetId: number): ((packet: Packet) => void)[] {
|
||||||
|
|||||||
@@ -1,151 +1,372 @@
|
|||||||
export default class Stream {
|
export default class Stream {
|
||||||
|
private stream: Uint8Array;
|
||||||
|
private readPointer = 0; // bits
|
||||||
|
private writePointer = 0; // bits
|
||||||
|
|
||||||
private _stream: number[];
|
constructor(stream?: Uint8Array | number[]) {
|
||||||
private _readPoiner: number = 0;
|
if (!stream) {
|
||||||
private _writePointer: number = 0;
|
this.stream = new Uint8Array(0);
|
||||||
|
} else {
|
||||||
|
const src = stream instanceof Uint8Array ? stream : Uint8Array.from(stream);
|
||||||
|
this.stream = src;
|
||||||
|
this.writePointer = this.stream.length << 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
constructor(stream : number[] = []) {
|
getStream(): Uint8Array {
|
||||||
this._stream = stream;
|
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[] {
|
this.writeBits(BigInt(v), 8);
|
||||||
return this._stream;
|
}
|
||||||
|
|
||||||
|
readUInt8(): number {
|
||||||
|
if (this.remainingBits() < 8n) {
|
||||||
|
throw new Error("Not enough bits to read UInt8");
|
||||||
}
|
}
|
||||||
|
|
||||||
public setStream(stream: number[]) {
|
if ((this.readPointer & 7) === 0) {
|
||||||
this._stream = stream;
|
const v = this.stream[this.readPointer >> 3] & 0xff;
|
||||||
|
this.readPointer += 8;
|
||||||
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeInt8(value: number) {
|
return Number(this.readBits(8));
|
||||||
const negationBit = value < 0 ? 1 : 0;
|
}
|
||||||
const int8Value = Math.abs(value) & 0xFF;
|
|
||||||
this._stream[this._writePointer >> 3] |= negationBit << (7 - (this._writePointer & 7));
|
writeInt8(value: number) {
|
||||||
this._writePointer++;
|
this.writeUInt8(value);
|
||||||
for (let i = 0; i < 8; i++) {
|
}
|
||||||
const bit = (int8Value >> (7 - i)) & 1;
|
|
||||||
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
|
readInt8(): number {
|
||||||
this._writePointer++;
|
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 {
|
const v = Math.floor(value);
|
||||||
let value = 0;
|
this.writeUInt8((v >>> 24) & 0xff);
|
||||||
const negationBit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
this.writeUInt8((v >>> 16) & 0xff);
|
||||||
this._readPoiner++;
|
this.writeUInt8((v >>> 8) & 0xff);
|
||||||
for (let i = 0; i < 8; i++) {
|
this.writeUInt8(v & 0xff);
|
||||||
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
}
|
||||||
value |= bit << (7 - i);
|
|
||||||
this._readPoiner++;
|
readUInt32(): number {
|
||||||
}
|
const b1 = this.readUInt8();
|
||||||
return negationBit ? -value : value;
|
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) {
|
this.writeUInt8(Number((value >> 56n) & 0xffn));
|
||||||
const bit = value & 1;
|
this.writeUInt8(Number((value >> 48n) & 0xffn));
|
||||||
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
|
this.writeUInt8(Number((value >> 40n) & 0xffn));
|
||||||
this._writePointer++;
|
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 requiredBits = BigInt(len) * 16n;
|
||||||
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
if (requiredBits > this.remainingBits()) {
|
||||||
this._readPoiner++;
|
throw new Error("Not enough bits to read string");
|
||||||
return bit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeBoolean(value: boolean) {
|
const chars = new Array<number>(len);
|
||||||
this.writeBit(value ? 1 : 0);
|
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 {
|
for (let i = 0; i < arr.length; i++) {
|
||||||
return this.readBit() === 1;
|
this.writeUInt8(arr[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 writeInt16(value: number) {
|
const out = new Uint8Array(len);
|
||||||
this.writeInt8(value >> 8);
|
|
||||||
this.writeInt8(value & 0xFF);
|
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 readInt16(): number {
|
for (let i = 0; i < len; i++) {
|
||||||
const value = this.readInt8() << 8;
|
out[i] = this.readUInt8();
|
||||||
return value | this.readInt8();
|
}
|
||||||
|
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 writeInt32(value: number) {
|
let value = 0n;
|
||||||
this.writeInt16(value >> 16);
|
for (let i = 0; i < bits; i++) {
|
||||||
this.writeInt16(value & 0xFFFF);
|
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 readInt32(): number {
|
this.ensureCapacity(Number(byteIndex));
|
||||||
const value = this.readInt16() << 16;
|
}
|
||||||
return value | this.readInt16();
|
|
||||||
}
|
private ensureCapacity(byteIndex: number) {
|
||||||
|
const requiredSize = byteIndex + 1;
|
||||||
public writeInt64(value: number) {
|
if (requiredSize <= this.stream.length) return;
|
||||||
const high = Math.floor(value / 0x100000000);
|
|
||||||
const low = value >>> 0;
|
let newSize = this.stream.length === 0 ? 32 : this.stream.length;
|
||||||
this.writeInt32(high);
|
while (newSize < requiredSize) {
|
||||||
this.writeInt32(low);
|
if (newSize > (0x7fffffff >> 1)) {
|
||||||
}
|
newSize = requiredSize;
|
||||||
|
break;
|
||||||
public readInt64(): number {
|
}
|
||||||
const high = this.readInt32();
|
newSize <<= 1;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const next = new Uint8Array(newSize);
|
||||||
|
next.set(this.stream);
|
||||||
|
this.stream = next;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
|||||||
interface TransportContextValue {
|
interface TransportContextValue {
|
||||||
transportServer: string | null;
|
transportServer: string | null;
|
||||||
uploadFile: (id: string, content: string) => Promise<any>;
|
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[];
|
uploading: TransportState[];
|
||||||
downloading: TransportState[];
|
downloading: TransportState[];
|
||||||
}
|
}
|
||||||
@@ -86,14 +86,14 @@ export function TransportProvider(props: TransportProviderProps) {
|
|||||||
* @param tag тег файла
|
* @param tag тег файла
|
||||||
* @param chachaDecryptedKey ключ для расшифровки файла
|
* @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) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!transportServerRef.current) {
|
if (!transportServer) {
|
||||||
throw new Error("Transport server is not set");
|
throw new Error("Transport server is not set");
|
||||||
}
|
}
|
||||||
setDownloading(prev => [...prev, { id: id, progress: 0 }]);
|
setDownloading(prev => [...prev, { id: id, progress: 0 }]);
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', `${transportServerRef.current}/d/${tag}`);
|
xhr.open('GET', `${transportServer}/d/${tag}`);
|
||||||
xhr.responseType = 'text';
|
xhr.responseType = 'text';
|
||||||
|
|
||||||
xhr.onprogress = (event) => {
|
xhr.onprogress = (event) => {
|
||||||
|
|||||||
12
app/providers/TransportProvider/useTransportServer.ts
Normal file
12
app/providers/TransportProvider/useTransportServer.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export const SERVERS = [
|
export const SERVERS = [
|
||||||
//'wss://cdn.rosetta-im.com',
|
//'wss://cdn.rosetta-im.com',
|
||||||
//'ws://10.211.55.2:3000',
|
//'ws://10.211.55.2:3000',
|
||||||
'ws://127.0.0.1:3000',
|
//'ws://192.168.6.82:3000',
|
||||||
//'wss://wss.rosetta.im'
|
'wss://wss.rosetta.im'
|
||||||
];
|
];
|
||||||
|
|
||||||
export function selectServer(): string {
|
export function selectServer(): string {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
|
|||||||
return "$a=File";
|
return "$a=File";
|
||||||
case AttachmentType.AVATAR:
|
case AttachmentType.AVATAR:
|
||||||
return "$a=Avatar";
|
return "$a=Avatar";
|
||||||
|
case AttachmentType.CALL:
|
||||||
|
return "$a=Call";
|
||||||
|
case AttachmentType.VOICE:
|
||||||
|
return "$a=Voice message";
|
||||||
default:
|
default:
|
||||||
return "[Unsupported attachment]";
|
return "[Unsupported attachment]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
export const APP_VERSION = "1.0.8";
|
export const APP_VERSION = "1.2.2";
|
||||||
export const CORE_MIN_REQUIRED_VERSION = "1.5.0";
|
export const CORE_MIN_REQUIRED_VERSION = "1.5.5";
|
||||||
|
|
||||||
export const RELEASE_NOTICE = `
|
export const RELEASE_NOTICE = `
|
||||||
**Обновление v1.0.8** :emoji_1f631:
|
**Обновление v1.2.2** :emoji_1f631:
|
||||||
- Фикс проблемы с загрузкой аватарок в некоторых случаях
|
- Поддержка записи и прослушивания голосовых сообщений
|
||||||
- Фикс фонового скролла при увеличении картинки
|
|
||||||
- Фикс артефактов у картинки
|
|
||||||
`;
|
`;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor } from 'electron'
|
import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor, app } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { WORKING_DIR } from './constants';
|
import { WORKING_DIR } from './constants';
|
||||||
@@ -28,8 +28,8 @@ export function createPreloaderWindow() {
|
|||||||
|
|
||||||
export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 900,
|
width: 385,
|
||||||
height: 670,
|
height: 555,
|
||||||
minWidth: 385,
|
minWidth: 385,
|
||||||
minHeight: 555,
|
minHeight: 555,
|
||||||
show: false,
|
show: false,
|
||||||
@@ -45,7 +45,8 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
|||||||
nodeIntegrationInSubFrames: true,
|
nodeIntegrationInSubFrames: true,
|
||||||
nodeIntegrationInWorker: true,
|
nodeIntegrationInWorker: true,
|
||||||
webSecurity: false,
|
webSecurity: false,
|
||||||
allowRunningInsecureContent: true
|
allowRunningInsecureContent: true,
|
||||||
|
autoplayPolicy: 'no-user-gesture-required'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
||||||
|
let bounceId: number | null = null;
|
||||||
ipcMain.removeAllListeners('window-resize');
|
ipcMain.removeAllListeners('window-resize');
|
||||||
ipcMain.removeAllListeners('window-resizeble');
|
ipcMain.removeAllListeners('window-resizeble');
|
||||||
ipcMain.removeAllListeners('window-theme');
|
ipcMain.removeAllListeners('window-theme');
|
||||||
@@ -86,6 +88,38 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
|||||||
ipcMain.removeHandler('window-minimize');
|
ipcMain.removeHandler('window-minimize');
|
||||||
ipcMain.removeHandler('showItemInFolder');
|
ipcMain.removeHandler('showItemInFolder');
|
||||||
ipcMain.removeHandler('openExternal');
|
ipcMain.removeHandler('openExternal');
|
||||||
|
ipcMain.removeHandler('window-top');
|
||||||
|
ipcMain.removeHandler('window-priority-normal');
|
||||||
|
|
||||||
|
ipcMain.handle('window-top', () => {
|
||||||
|
if (mainWindow.isMinimized()) {
|
||||||
|
mainWindow.restore();
|
||||||
|
}
|
||||||
|
mainWindow.setAlwaysOnTop(true, "screen-saver"); // самый высокий уровень
|
||||||
|
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||||
|
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
/**
|
||||||
|
* Только в macos! Подпрыгивание иконки в Dock
|
||||||
|
*/
|
||||||
|
bounceId = app.dock!.bounce("critical");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('window-priority-normal', () => {
|
||||||
|
mainWindow.setAlwaysOnTop(false);
|
||||||
|
mainWindow.setVisibleOnAllWorkspaces(false);
|
||||||
|
if (process.platform === "darwin" && bounceId !== null) {
|
||||||
|
/**
|
||||||
|
* Только в macos! Отмена подпрыгивания иконки в Dock
|
||||||
|
*/
|
||||||
|
app.dock!.cancelBounce(bounceId);
|
||||||
|
bounceId = null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('open-dev-tools', () => {
|
ipcMain.handle('open-dev-tools', () => {
|
||||||
if (mainWindow.webContents.isDevToolsOpened()) {
|
if (mainWindow.webContents.isDevToolsOpened()) {
|
||||||
|
|||||||
13
lib/main/ipcs/ipcRuntime.ts
Normal file
13
lib/main/ipcs/ipcRuntime.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { app, ipcMain } from "electron";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить директорию с ресурсами приложения
|
||||||
|
*/
|
||||||
|
ipcMain.handle('runtime:get-resources', () => {
|
||||||
|
const isDev = !app.isPackaged && process.env['ELECTRON_RENDERER_URL'];
|
||||||
|
if(isDev){
|
||||||
|
return path.join(process.cwd(), "resources")
|
||||||
|
}
|
||||||
|
return path.join(process.resourcesPath, "resources");
|
||||||
|
});
|
||||||
174
lib/main/main.ts
174
lib/main/main.ts
@@ -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 { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||||
import { createAppWindow, startApplication } from './app'
|
import { createAppWindow, startApplication } from './app'
|
||||||
import './ipcs/ipcDatabase'
|
import './ipcs/ipcDatabase'
|
||||||
@@ -8,96 +8,138 @@ import './ipcs/ipcUpdate'
|
|||||||
import './ipcs/ipcNotification'
|
import './ipcs/ipcNotification'
|
||||||
import './ipcs/ipcDevice'
|
import './ipcs/ipcDevice'
|
||||||
import './ipcs/ipcCore'
|
import './ipcs/ipcCore'
|
||||||
import { Tray } from 'electron/main'
|
import './ipcs/ipcRuntime'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { Logger } from './logger'
|
import { Logger } from './logger'
|
||||||
|
|
||||||
let lockInstance = app.requestSingleInstanceLock();
|
const lockInstance = app.requestSingleInstanceLock()
|
||||||
let tray : Tray | null = null;
|
let tray: Tray | null = null
|
||||||
const size = process.platform === 'darwin' ? 18 : 22;
|
const size = process.platform === 'darwin' ? 18 : 22
|
||||||
const logger = Logger('main');
|
const logger = Logger('main')
|
||||||
|
|
||||||
|
const icon = nativeImage
|
||||||
|
.createFromPath(join(__dirname, '../../resources/R.png'))
|
||||||
|
.resize({ width: size, height: size })
|
||||||
|
|
||||||
const icon = nativeImage.createFromPath(
|
if (!lockInstance) {
|
||||||
join(__dirname, '../../resources/R.png')
|
app.quit()
|
||||||
).resize({ width: size, height: size });
|
process.exit(0)
|
||||||
|
|
||||||
if(!lockInstance){
|
|
||||||
app.quit();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
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', () => {
|
app.on('second-instance', () => {
|
||||||
// Someone tried to run a second instance, we should focus our window.
|
const allWindows = BrowserWindow.getAllWindows()
|
||||||
const allWindows = BrowserWindow.getAllWindows();
|
if (allWindows.length) {
|
||||||
if (allWindows.length) {
|
const mainWindow = allWindows[0]
|
||||||
const mainWindow = allWindows[0];
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
if (!mainWindow.isVisible()) mainWindow.show()
|
||||||
if (mainWindow.isVisible() === false) mainWindow.show();
|
mainWindow.focus()
|
||||||
mainWindow.focus();
|
}
|
||||||
}
|
})
|
||||||
});
|
|
||||||
|
|
||||||
export const restoreApplicationAfterClickOnTrayOrDock = () => {
|
export const restoreApplicationAfterClickOnTrayOrDock = () => {
|
||||||
const allWindows = BrowserWindow.getAllWindows();
|
const allWindows = BrowserWindow.getAllWindows()
|
||||||
if (allWindows.length > 0) {
|
if (allWindows.length > 0) {
|
||||||
const mainWindow = allWindows[0];
|
const mainWindow = allWindows[0]
|
||||||
if (mainWindow.isMinimized()){
|
if (mainWindow.isMinimized()) {
|
||||||
mainWindow.restore();
|
mainWindow.restore()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
if(mainWindow.isVisible() === false){
|
if (!mainWindow.isVisible()) {
|
||||||
mainWindow.show();
|
mainWindow.show()
|
||||||
}
|
}
|
||||||
mainWindow.focus();
|
mainWindow.focus()
|
||||||
} else {
|
} else {
|
||||||
createAppWindow();
|
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 () => {
|
app.whenReady().then(async () => {
|
||||||
electronApp.setAppUserModelId('Rosetta');
|
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
|
// Убираем File/View и оставляем только app + минимальный Edit (roles)
|
||||||
// and ignore CommandOrControl + R in production.
|
if (process.platform === 'darwin') {
|
||||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
const minimalMenu = Menu.buildFromTemplate([
|
||||||
app.on('browser-window-created', (_, window) => {
|
{
|
||||||
optimizer.watchWindowShortcuts(window)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
app.on('activate', function () {
|
tray = new Tray(icon)
|
||||||
restoreApplicationAfterClickOnTrayOrDock();
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform == 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
app.hide();
|
app.hide()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// 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.
|
|
||||||
|
|||||||
3
lib/preload/index.d.ts
vendored
3
lib/preload/index.d.ts
vendored
@@ -13,5 +13,8 @@ declare global {
|
|||||||
downloadsPath: string;
|
downloadsPath: string;
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
|
mediaApi: {
|
||||||
|
getSoundUrl: (fileName: string) => Promise<string>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { contextBridge, ipcRenderer, shell } from 'electron'
|
import { contextBridge, ipcRenderer, shell } from 'electron'
|
||||||
import { electronAPI } from '@electron-toolkit/preload'
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
import api from './api'
|
import api from './api'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import path from 'node:path'
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
|
||||||
|
async function resolveSound(fileName: string) {
|
||||||
|
const resourcesPath = await ipcRenderer.invoke('runtime:get-resources');
|
||||||
|
const fullPath = path.join(resourcesPath, "sounds", fileName);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
throw new Error(`Sound not found: ${fullPath}`);
|
||||||
|
}
|
||||||
|
return pathToFileURL(fullPath).toString();
|
||||||
|
}
|
||||||
|
|
||||||
const exposeContext = async () => {
|
const exposeContext = async () => {
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
try {
|
try {
|
||||||
@@ -16,6 +28,11 @@ const exposeContext = async () => {
|
|||||||
ipcRenderer.invoke('ipcCore:showItemInFolder', fullPath);
|
ipcRenderer.invoke('ipcCore:showItemInFolder', fullPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
contextBridge.exposeInMainWorld("mediaApi", {
|
||||||
|
getSoundUrl: async (fileName: string) => {
|
||||||
|
return resolveSound(fileName);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -23,6 +40,11 @@ const exposeContext = async () => {
|
|||||||
window.electron = electronAPI
|
window.electron = electronAPI
|
||||||
window.api = api;
|
window.api = api;
|
||||||
window.shell = shell;
|
window.shell = shell;
|
||||||
|
window.mediaApi = {
|
||||||
|
getSoundUrl: async (fileName: string) => {
|
||||||
|
return resolveSound(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -1,11 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "Rosetta",
|
"name": "Rosetta",
|
||||||
"version": "1.5.0",
|
"version": "1.5.5",
|
||||||
"description": "Rosetta Messenger",
|
"description": "Rosetta Messenger",
|
||||||
"main": "./out/main/main.js",
|
"main": "./out/main/main.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"build": {
|
"build": {
|
||||||
"electronUpdaterCompatibility": false,
|
"electronUpdaterCompatibility": false,
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "resources/",
|
||||||
|
"to": "resources/"
|
||||||
|
}
|
||||||
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"node_modules/sqlite3/**/*",
|
"node_modules/sqlite3/**/*",
|
||||||
"out/main/**/*",
|
"out/main/**/*",
|
||||||
@@ -78,7 +84,7 @@
|
|||||||
"@mantine/form": "^8.3.12",
|
"@mantine/form": "^8.3.12",
|
||||||
"@mantine/hooks": "^8.3.12",
|
"@mantine/hooks": "^8.3.12",
|
||||||
"@mantine/modals": "^8.3.12",
|
"@mantine/modals": "^8.3.12",
|
||||||
"@noble/ciphers": "^1.2.1",
|
"@noble/ciphers": "^1.3.0",
|
||||||
"@noble/secp256k1": "^3.0.0",
|
"@noble/secp256k1": "^3.0.0",
|
||||||
"@tabler/icons-react": "^3.31.0",
|
"@tabler/icons-react": "^3.31.0",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
@@ -105,6 +111,8 @@
|
|||||||
"jsencrypt": "^3.3.2",
|
"jsencrypt": "^3.3.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"libsodium": "^0.8.2",
|
"libsodium": "^0.8.2",
|
||||||
|
"libsodium-wrappers": "^0.8.2",
|
||||||
|
"libsodium-wrappers-sumo": "^0.8.2",
|
||||||
"lottie-react": "^2.4.1",
|
"lottie-react": "^2.4.1",
|
||||||
"node-forge": "^1.3.1",
|
"node-forge": "^1.3.1",
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
@@ -130,6 +138,7 @@
|
|||||||
"@electron/rebuild": "^4.0.3",
|
"@electron/rebuild": "^4.0.3",
|
||||||
"@rushstack/eslint-patch": "^1.10.5",
|
"@rushstack/eslint-patch": "^1.10.5",
|
||||||
"@tailwindcss/vite": "^4.0.9",
|
"@tailwindcss/vite": "^4.0.9",
|
||||||
|
"@types/libsodium-wrappers": "^0.7.14",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.5",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
|
|||||||
10
problems/problem_calls.md
Normal file
10
problems/problem_calls.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Проблема обмена ключами в звонках
|
||||||
|
|
||||||
|
Для того, чтобы звонки работали защищенно, необходимо обмениваться ключами между участниками. При этом, сам ключ не должен передаваться по сети, иначе его смогут перехватить злоумышленники. Поэтому, для обмена ключами используется специальный протокол, который позволяет участникам обмениваться ключами без передачи их по сети. В Rosetta можно было использовать уже известный и проверенный контур шифрования по которому шифруются сообщения с использованием публичных и приватных ключей. Однако реализация такого метода может обернуться проблемами при эксплуатации, так как при ответе на звонок, участник может не иметь доступа к приватному ключу, который используется для дешифровки сообщений (например, при ответе на звонок с телефона, когда в приложение не был совершен вход, то есть оно выгружено из памяти), в этом случае звонок не будет работать, так как участник не сможет дешифровать голос и видео из звонка.
|
||||||
|
|
||||||
|
## Возможное решение
|
||||||
|
Можно заставлять пользователя входить в приложение при ответе на звонок, чтобы он мог получить доступ к приватному ключу и дешифровать звонок. Однако, это может привести к неудобствам для пользователей, так как им придется каждый раз входить в приложение при ответе на звонок, что может быть особенно проблематично при использовании мобильного устройства.
|
||||||
|
|
||||||
|
## Решение использованное в Rosetta
|
||||||
|
Для решения проблемы обмена ключами в звонках, в Rosetta используется алгоритм Диффи-Хеллмана для генерации общего секрета между участниками звонка. Этот алгоритм позволяет участникам обмениваться публичными ключами и генерировать общий секрет, который используется для шифрования и дешифрования медиа-потока в звонке. При этом, это не требует входа в приложение, так как ключ генерируется случайный при каждом звонке, и не зависит от приватного ключа. Это обеспечивает удобство для пользователей, так как им не нужно входить в приложение при ответе на звонок, и при этом обеспечивает безопасность звонков, так как ключи не передаются по сети и генерируются случайным образом для каждого звонка. Таким образом, Rosetta обеспечивает безопасные и удобные звонки для пользователей.
|
||||||
|
|
||||||
BIN
resources/sounds/calling.mp3
Normal file
BIN
resources/sounds/calling.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/connected.mp3
Normal file
BIN
resources/sounds/connected.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/end_call.mp3
Normal file
BIN
resources/sounds/end_call.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/micro_disable.mp3
Normal file
BIN
resources/sounds/micro_disable.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/micro_enable.mp3
Normal file
BIN
resources/sounds/micro_enable.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/ringtone.mp3
Normal file
BIN
resources/sounds/ringtone.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/sound_disable.mp3
Normal file
BIN
resources/sounds/sound_disable.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/sound_enable.mp3
Normal file
BIN
resources/sounds/sound_enable.mp3
Normal file
Binary file not shown.
@@ -5,6 +5,7 @@
|
|||||||
"composite": true,
|
"composite": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"lib": ["DOM"],
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"types": ["electron-vite/node"],
|
"types": ["electron-vite/node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
Reference in New Issue
Block a user