Compare commits
227 Commits
cdf98d3d8d
...
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 | ||
|
|
e79282755b | ||
|
|
e06d58facf | ||
|
|
7a89a3a307 | ||
|
|
9ad0e5d00a | ||
|
|
9eac2fae6f | ||
|
|
461ccbfa94 | ||
|
|
8b16c4ce0f | ||
|
|
c3a53b517e | ||
|
|
b9603462a0 | ||
|
|
84d3cc7be4 | ||
|
|
a431b23476 | ||
|
|
88369171b6 | ||
| e2b767779f | |||
|
|
4e42eb3c02 | ||
|
|
49d7d9ff62 | ||
|
|
fabd85106d | ||
|
|
0b3bdface8 | ||
| 6a0a97798d | |||
|
|
7c806149b3 | ||
|
|
f1fb7ba252 | ||
|
|
453cc55fc0 | ||
|
|
785406671c | ||
|
|
089fa055d3 | ||
|
|
fbc4f73f3d | ||
|
|
bf057c14f4 | ||
|
|
8952fe43e8 | ||
| 1c8493b33f | |||
|
|
a9ce892ea2 | ||
|
|
fe418dabc9 | ||
|
|
1572f06ef4 | ||
| 6054be7f5c | |||
|
|
a19803fc63 | ||
|
|
0349f2815e | ||
|
|
c720ed631c | ||
|
|
443433e61f | ||
| d324ad5c57 | |||
|
|
0189998d54 | ||
| b68cfe851f | |||
| a9ebc136ab | |||
| e473524c4b | |||
|
|
6d75225635 | ||
| 991310592f | |||
|
|
d6e14e470a | ||
| 8ffa66920e | |||
|
|
48909a7eeb | ||
|
|
9379bd656d | ||
|
|
142082ba83 | ||
|
|
c64a7005d3 | ||
|
|
db72246e5a | ||
|
|
d53c987e7e | ||
|
|
2e18d489be | ||
| 62f3bd5e35 | |||
|
|
f01ed34285 | ||
|
|
9c8d3865a6 | ||
|
|
a8ec08f0f5 | ||
|
|
c41463d88f | ||
|
|
66a3beec2c | ||
|
|
d435809ae8 | ||
|
|
b31c757a32 | ||
|
|
5741097334 | ||
|
|
95a1f57381 | ||
|
|
53535d68e0 | ||
|
|
a38a331cd1 | ||
|
|
6908dd486c | ||
|
|
026a3c9520 | ||
|
|
a0c73c807f | ||
|
|
ff96dfd204 | ||
|
|
f959c6335c | ||
|
|
bb55fb47aa | ||
|
|
f8ca15422f | ||
| eee419c0d4 | |||
|
|
2692c941e1 | ||
|
|
4248abc629 | ||
|
|
782f156070 | ||
|
|
6670956a51 | ||
|
|
67ef2b7ea7 | ||
| 53a9c0037f | |||
|
|
c50f3f0097 | ||
|
|
2a5e297478 | ||
|
|
d55073b7bc | ||
|
|
3f239ed82b | ||
|
|
9410b0f1e6 | ||
|
|
3735f680d2 | ||
|
|
7472747a9e | ||
|
|
0ac5ecee8a | ||
|
|
597e2e0532 | ||
|
|
6a69c3b195 | ||
|
|
c24c1c7fd9 | ||
|
|
f6a57762c0 | ||
|
|
3a854c8d27 | ||
|
|
65089fd71b | ||
|
|
5470df481d | ||
|
|
05429a0726 | ||
|
|
fc73cf2acf | ||
|
|
779fa202b8 | ||
|
|
6a8269afa1 | ||
|
|
d6ce12a007 | ||
|
|
eb1cf90d03 | ||
|
|
319086218d | ||
|
|
3eb2b7f493 | ||
|
|
6bcdaaf067 | ||
|
|
95b0f1e056 | ||
|
|
d928b48dbd | ||
|
|
171ed60194 | ||
|
|
8f12be2c5d | ||
|
|
bbab09a936 | ||
|
|
1c873bb118 | ||
|
|
8cabb212c1 | ||
|
|
e39d084dad | ||
|
|
b0998990fd | ||
|
|
73f32cf429 | ||
|
|
9a15383d65 |
@@ -1,30 +0,0 @@
|
||||
name: Build Windows
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build Windows
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: Windows
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install npm dependencies
|
||||
run: npm install
|
||||
- name: Build the application
|
||||
run: npm run kernel:win
|
||||
- name: Upload build on SSH
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USERNAME }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
password: ${{ secrets.SSH_PASSWORD }}
|
||||
source: "/dist/builds/win/x64/Rosetta-*.exe"
|
||||
target: ${{ secrets.SSH_TARGET_DIR }}
|
||||
82
.gitea/workflows/darwin.yaml
Normal file
82
.gitea/workflows/darwin.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
name: MacOS Kernel Build
|
||||
run-name: Build and Upload MacOS Kernel
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'lib/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Кэш npm (тарифы грузятся из ~/.npm-cache на macOS)
|
||||
- name: Cache npm cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.HOME }}/.npm-cache
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Кэш для electron-builder
|
||||
- name: Cache electron-builder
|
||||
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
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist/builds/darwin/arm64 dist/builds/darwin/x64
|
||||
npm config set cache "$HOME/.npm-cache" --global
|
||||
npm config set prefer-offline true --global
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm install --prefer-offline --no-audit --no-fund
|
||||
|
||||
- name: Build the application
|
||||
run: |
|
||||
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
|
||||
64
.gitea/workflows/linux.yaml
Normal file
64
.gitea/workflows/linux.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Linux Kernel Build
|
||||
run-name: Build and Upload Linux Kernel
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'lib/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
out_dir: x86_64
|
||||
- arch: arm64
|
||||
out_dir: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Debug ARCH
|
||||
run: |
|
||||
echo "arch=${{ matrix.arch }}"
|
||||
echo "out_dir=${{ matrix.out_dir }}"
|
||||
|
||||
- name: Build the application
|
||||
run: |
|
||||
mkdir -p dist/builds/linux/x64
|
||||
mkdir -p dist/builds/linux/${{ matrix.out_dir }}
|
||||
npx electron-vite build
|
||||
npx electron-builder --linux --${{ matrix.arch }}
|
||||
|
||||
- name: Check if files exist
|
||||
run: |
|
||||
echo "=== Checking dist structure ==="
|
||||
find dist/builds/linux/${{ matrix.out_dir }} -type f -name "*.AppImage" 2>/dev/null || echo "No AppImage files found"
|
||||
ls -la dist/builds/linux/${{ matrix.out_dir }}/ 2>/dev/null || echo "arch folder not found"
|
||||
|
||||
- name: Upload to SSH using SCP
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SDU_SSH_HOST }}
|
||||
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||
port: 22
|
||||
source: dist/builds/linux/${{ matrix.out_dir }}/Rosetta-*.AppImage
|
||||
target: ${{ secrets.SDU_SSH_KERNEL }}/linux/${{ matrix.arch }}
|
||||
strip_components: 4
|
||||
rm: true
|
||||
91
.gitea/workflows/service-packs.yaml
Normal file
91
.gitea/workflows/service-packs.yaml
Normal file
@@ -0,0 +1,91 @@
|
||||
name: SP Builds
|
||||
run-name: Build and Upload SP Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'app/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: NPM offline setup
|
||||
shell: bash
|
||||
run: |
|
||||
npm config set cache "$HOME/.npm-cache" --global
|
||||
npm config set prefer-offline true --global
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm install --prefer-offline --no-audit --no-fund
|
||||
|
||||
#Собираем Kernel чтобы свежие файлы попали в папку out
|
||||
- name: Build the application
|
||||
run: npm run kernel:linux
|
||||
|
||||
- name: Install ZIP in Docker container
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y zip
|
||||
|
||||
#Собираем сервисные пакеты для всех платформ
|
||||
- name: Build SP
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x "$GITHUB_WORKSPACE/build-packs.sh"
|
||||
sh "$GITHUB_WORKSPACE/build-packs.sh"
|
||||
#Загружаем на удаленный сервер по SSH используя scp и пароль из секретов
|
||||
#Загружаем из двух папок dist/builds/darwin/x64 и dist/builds/darwin/arm64, так как electron-builder может создавать разные файлы для разных архитектур
|
||||
# - name: Upload to SSH
|
||||
# shell: bash
|
||||
# run: |
|
||||
# chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
|
||||
# sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
|
||||
# -l "$GITHUB_WORKSPACE/packs/*" \
|
||||
# -r "${{ secrets.SDU_SSH_PACKS }}" \
|
||||
# -s "${{ secrets.SDU_SSH_HOST }}" \
|
||||
# -u "${{ secrets.SDU_SSH_USERNAME }}" \
|
||||
# -p '${{ secrets.SDU_SSH_PASSWORD }}'
|
||||
|
||||
- name: Check SP
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Workspace ==="
|
||||
pwd
|
||||
ls -la
|
||||
echo "=== Packs ==="
|
||||
find packs -maxdepth 3 -type f 2>/dev/null || true
|
||||
test -n "$(find packs -type f -print -quit 2>/dev/null)" || { echo "packs is empty"; exit 1; }
|
||||
|
||||
- name: Clean files before upload
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.SDU_SSH_HOST }}
|
||||
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||
port: 22
|
||||
script: |
|
||||
mkdir -p "${{ secrets.SDU_SSH_PACKS }}"
|
||||
find "${{ secrets.SDU_SSH_PACKS }}" -mindepth 1 -type f -delete
|
||||
|
||||
- name: Upload to SSH using SCP
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SDU_SSH_HOST }}
|
||||
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||
port: 22
|
||||
source: "packs/*"
|
||||
target: "${{ secrets.SDU_SSH_PACKS }}"
|
||||
strip_components: 1
|
||||
rm: false
|
||||
355
.gitea/workflows/sshupload.ps1
Normal file
355
.gitea/workflows/sshupload.ps1
Normal file
@@ -0,0 +1,355 @@
|
||||
# PowerShell script to upload files to SFTP server with remote folder cleanup
|
||||
# Works on clean Windows without additional utilities (uses WinSCP)
|
||||
|
||||
# ==========================================
|
||||
# PARAMETERS (can override config values via command line)
|
||||
# ==========================================
|
||||
param(
|
||||
[Parameter(Mandatory=$false, HelpMessage="SFTP server IP address or hostname")]
|
||||
[string]$ServerAddress,
|
||||
|
||||
[Parameter(Mandatory=$false, HelpMessage="Username for connection")]
|
||||
[string]$Username,
|
||||
|
||||
[Parameter(Mandatory=$false, HelpMessage="Password for connection")]
|
||||
[string]$PasswordParam,
|
||||
|
||||
[Parameter(Mandatory=$false, HelpMessage="Local file path or pattern (e.g., C:\files\* or dist/builds/x64/Rosetta-*.exe)")]
|
||||
[string]$LocalFilePath,
|
||||
|
||||
[Parameter(Mandatory=$false, HelpMessage="Remote folder on server")]
|
||||
[string]$RemoteFolderPath,
|
||||
|
||||
[Parameter(Mandatory=$false, HelpMessage="SSH port")]
|
||||
[int]$Port,
|
||||
|
||||
[Parameter(Mandatory=$false, HelpMessage="Path to WinSCP executable (auto-detect if not provided)")]
|
||||
[string]$WinSCPPath
|
||||
)
|
||||
|
||||
# ==========================================
|
||||
# CONFIGURATION - Default fallback values
|
||||
# ==========================================
|
||||
# These values are used only if not provided via command-line parameters or environment variables
|
||||
$CONFIG_ServerAddress = ""
|
||||
$CONFIG_Username = ""
|
||||
$CONFIG_Password = ""
|
||||
$CONFIG_LocalFilePath = ""
|
||||
$CONFIG_RemoteFolderPath = ""
|
||||
$CONFIG_Port = 22
|
||||
$CONFIG_WinSCPPath = ""
|
||||
|
||||
# Priority: Command-line Parameters (highest) > Environment Variables > Config Values (lowest)
|
||||
# If parameter not provided via command line, check environment variable, then use config value
|
||||
if (-not $ServerAddress) {
|
||||
$ServerAddress = if ($env:SFTP_SERVER) { $env:SFTP_SERVER } else { $CONFIG_ServerAddress }
|
||||
}
|
||||
if (-not $Username) {
|
||||
$Username = if ($env:SFTP_USERNAME) { $env:SFTP_USERNAME } else { $CONFIG_Username }
|
||||
}
|
||||
# Если пароль передан через CLI (-PasswordParam), используем его даже если пустая строка
|
||||
if (-not $PSBoundParameters.ContainsKey('PasswordParam')) {
|
||||
$PasswordParam = if ($env:SFTP_PASSWORD) { $env:SFTP_PASSWORD } else { $CONFIG_Password }
|
||||
}
|
||||
if (-not $LocalFilePath) {
|
||||
$LocalFilePath = if ($env:SFTP_LOCAL_PATH) { $env:SFTP_LOCAL_PATH } else { $CONFIG_LocalFilePath }
|
||||
}
|
||||
if (-not $RemoteFolderPath) {
|
||||
$RemoteFolderPath = if ($env:SFTP_REMOTE_PATH) { $env:SFTP_REMOTE_PATH } else { $CONFIG_RemoteFolderPath }
|
||||
}
|
||||
if (-not $Port -or $Port -eq 0) {
|
||||
$Port = if ($env:SFTP_PORT) { [int]$env:SFTP_PORT } else { $CONFIG_Port }
|
||||
}
|
||||
if (-not $WinSCPPath) {
|
||||
$WinSCPPath = if ($env:WINSCP_PATH) { $env:WINSCP_PATH } else { $CONFIG_WinSCPPath }
|
||||
}
|
||||
|
||||
# Validate required parameters
|
||||
$requiredParams = @(
|
||||
@{Name = "ServerAddress"; Value = $ServerAddress},
|
||||
@{Name = "Username"; Value = $Username},
|
||||
@{Name = "PasswordParam"; Value = $PasswordParam},
|
||||
@{Name = "LocalFilePath"; Value = $LocalFilePath},
|
||||
@{Name = "RemoteFolderPath"; Value = $RemoteFolderPath}
|
||||
)
|
||||
|
||||
$missingParams = @()
|
||||
foreach ($param in $requiredParams) {
|
||||
if ([string]::IsNullOrWhiteSpace($param.Value)) {
|
||||
$missingParams += $param.Name
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingParams.Count -gt 0) {
|
||||
Write-Host "ERROR: Missing required parameters: $($missingParams -join ', ')" -ForegroundColor Red
|
||||
Write-Host "Please configure values in the script CONFIG section or pass them as parameters." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Logging function
|
||||
function Write-Log {
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Message = "(empty message)",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Info", "Warning", "Error", "Success")]
|
||||
[string]$Level = "Info"
|
||||
)
|
||||
|
||||
# Handle null or empty messages
|
||||
if ([string]::IsNullOrWhiteSpace($Message)) {
|
||||
$Message = "(empty message)"
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$color = switch ($Level) {
|
||||
"Error" { "Red" }
|
||||
"Warning" { "Yellow" }
|
||||
"Success" { "Green" }
|
||||
default { "White" }
|
||||
}
|
||||
|
||||
Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color
|
||||
}
|
||||
|
||||
# Function to find WinSCP installation
|
||||
function Find-WinSCP {
|
||||
$possiblePaths = @(
|
||||
"C:\Program Files\WinSCP\WinSCP.com",
|
||||
"C:\Program Files (x86)\WinSCP\WinSCP.com",
|
||||
"C:\Program Files\WinSCP\WinSCP.exe",
|
||||
"C:\Program Files (x86)\WinSCP\WinSCP.exe",
|
||||
"C:\Program Files\WinSCP\WinSCPPortable.exe",
|
||||
"C:\Program Files (x86)\WinSCP\WinSCPPortable.exe"
|
||||
)
|
||||
|
||||
foreach ($path in $possiblePaths) {
|
||||
if (Test-Path $path) {
|
||||
Write-Log "Found WinSCP at: $path" "Info"
|
||||
return $path
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "WinSCP not found. Please install it from https://winscp.net/" "Error"
|
||||
return $null
|
||||
}
|
||||
|
||||
# Main upload function using WinSCP
|
||||
function Upload-ToSFTP {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Server,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$User,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Pass,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string[]]$FileList,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$RemotePath,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[int]$PortNum,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$WinSCPExe
|
||||
)
|
||||
|
||||
# Decode once if URL-encoded; we will pass plain password via -password switch (no extra encoding needed)
|
||||
$decodedPassword = if ($Pass -match '%[0-9A-Fa-f]{2}') { [System.Net.WebUtility]::UrlDecode($Pass) } else { $Pass }
|
||||
|
||||
# Create temporary file paths BEFORE script content (needed for variable expansion)
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss_fff'
|
||||
$debugDir = Join-Path $env:TEMP "winscp_debug"
|
||||
if (-not (Test-Path $debugDir)) {
|
||||
New-Item -ItemType Directory -Path $debugDir -Force | Out-Null
|
||||
}
|
||||
$scriptPath = Join-Path $debugDir "script_$timestamp.txt"
|
||||
$logFile = Join-Path $debugDir "log_$timestamp.txt"
|
||||
$outputPath = Join-Path $debugDir "output_$timestamp.txt"
|
||||
$errorPath = Join-Path $debugDir "error_$timestamp.txt"
|
||||
|
||||
# Create WinSCP script file WITH password (use @"..."@ to expand variables)
|
||||
$scriptContent = @"
|
||||
option batch abort
|
||||
option confirm off
|
||||
option echo off
|
||||
option reconnecttime 3
|
||||
"@
|
||||
|
||||
# Add connection string with auto-accept of host key; pass password via -password to avoid URL encoding issues
|
||||
$scriptContent += "`r`nopen sftp://$User@$Server`:$PortNum/ -password=`"$decodedPassword`" -hostkey=`"*`"`r`n"
|
||||
# Try to clear remote folder by removing all .exe files (ignore if none exist)
|
||||
$scriptContent += "call rm -f $RemotePath/*.exe`r`n"
|
||||
|
||||
# Add files to WinSCP script
|
||||
if ($FileList.Count -eq 0) {
|
||||
Write-Log "No files found matching pattern" "Warning"
|
||||
$scriptContent += "exit`r`n"
|
||||
}
|
||||
else {
|
||||
foreach ($filePath in $FileList) {
|
||||
# For local Windows paths, keep backslashes as-is (don't convert to forward slashes)
|
||||
# WinSCP needs native Windows paths for local files
|
||||
$remoteFilename = Split-Path $filePath -Leaf
|
||||
$scriptContent += "put `"$filePath`" `"$RemotePath/$remoteFilename`"`r`n"
|
||||
}
|
||||
$scriptContent += "close`r`nexit`r`n"
|
||||
}
|
||||
|
||||
# Save script to temporary file
|
||||
try {
|
||||
Set-Content -Path $scriptPath -Value $scriptContent -Encoding UTF8
|
||||
|
||||
Write-Log "Created WinSCP script at: $scriptPath" "Info"
|
||||
Write-Log "Script content:" "Info"
|
||||
Get-Content $scriptPath | ForEach-Object { Write-Log "$_" "Info" }
|
||||
|
||||
Write-Log "Executing WinSCP: $WinSCPExe" "Info"
|
||||
|
||||
try {
|
||||
# Determine if this is .com (command-line) or .exe (GUI)
|
||||
$isCom = $WinSCPExe -like "*.com"
|
||||
|
||||
if ($isCom) {
|
||||
# WinSCP.com uses /log= for logging
|
||||
$process = Start-Process -FilePath $WinSCPExe `
|
||||
-ArgumentList "/log=$logFile /script=$scriptPath" `
|
||||
-NoNewWindow `
|
||||
-PassThru `
|
||||
-Wait `
|
||||
-RedirectStandardOutput $outputPath `
|
||||
-RedirectStandardError $errorPath
|
||||
}
|
||||
else {
|
||||
# WinSCP.exe (GUI) - needs option logfile in script
|
||||
$scriptContent += "`r`noption logfile=$logFile"
|
||||
Set-Content -Path $scriptPath -Value $scriptContent -Encoding UTF8
|
||||
|
||||
$process = Start-Process -FilePath $WinSCPExe `
|
||||
-ArgumentList "/console /script=$scriptPath" `
|
||||
-NoNewWindow `
|
||||
-PassThru `
|
||||
-Wait `
|
||||
-RedirectStandardOutput $outputPath `
|
||||
-RedirectStandardError $errorPath
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Error starting process: $_" "Error"
|
||||
throw
|
||||
}
|
||||
|
||||
Write-Log "WinSCP process finished with exit code: $($process.ExitCode)" "Info"
|
||||
|
||||
# Read WinSCP logs
|
||||
$winscp_log = Get-Content $logFile -ErrorAction SilentlyContinue -Raw
|
||||
$output = Get-Content $outputPath -ErrorAction SilentlyContinue -Raw
|
||||
$error_output = Get-Content $errorPath -ErrorAction SilentlyContinue -Raw
|
||||
|
||||
if ($winscp_log) {
|
||||
Write-Log "WinSCP Log:`r`n$winscp_log" "Info"
|
||||
}
|
||||
|
||||
if ($output) {
|
||||
Write-Log "Output:`r`n$output" "Info"
|
||||
}
|
||||
else {
|
||||
Write-Log "No standard output from WinSCP" "Info"
|
||||
}
|
||||
|
||||
if ($error_output) {
|
||||
Write-Log "Standard Error:`r`n$error_output" "Error"
|
||||
}
|
||||
|
||||
if ($process.ExitCode -eq 0) {
|
||||
Write-Log "Upload completed successfully" "Success"
|
||||
return $true
|
||||
}
|
||||
else {
|
||||
Write-Log "Upload failed with exit code: $($process.ExitCode)" "Error"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Error during upload: $_" "Error"
|
||||
return $false
|
||||
}
|
||||
finally {
|
||||
# Cleanup temporary files
|
||||
Start-Sleep -Milliseconds 500
|
||||
if (Test-Path $scriptPath) {
|
||||
Remove-Item $scriptPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (Test-Path $logFile) {
|
||||
Remove-Item $logFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (Test-Path $outputPath) {
|
||||
Remove-Item $outputPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (Test-Path $errorPath) {
|
||||
Remove-Item $errorPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# =================
|
||||
# MAIN LOGIC
|
||||
# =================
|
||||
|
||||
Write-Log "========== STARTING FILE UPLOAD PROCESS ==========" "Info"
|
||||
Write-Log "Server: $ServerAddress`:$Port" "Info"
|
||||
Write-Log "Username: $Username" "Info"
|
||||
Write-Log "File pattern: $LocalFilePath" "Info"
|
||||
Write-Log "Remote folder: $RemoteFolderPath" "Info"
|
||||
Write-Log "=============================================" "Info"
|
||||
|
||||
# Find WinSCP if path not provided
|
||||
if (-not $WinSCPPath) {
|
||||
$WinSCPPath = Find-WinSCP
|
||||
if (-not $WinSCPPath) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Verify WinSCP exists
|
||||
if (-not (Test-Path $WinSCPPath)) {
|
||||
Write-Log "Error: WinSCP not found at: $WinSCPPath" "Error"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Get files matching pattern
|
||||
$files = @(Get-Item -Path $LocalFilePath -ErrorAction SilentlyContinue | Where-Object {-not $_.PSIsContainer})
|
||||
|
||||
if ($files.Count -eq 0) {
|
||||
Write-Log "Error: No files found matching pattern: $LocalFilePath" "Error"
|
||||
Write-Log "Current directory: $(Get-Location)" "Error"
|
||||
Write-Log "Checking if path exists: $(Test-Path $LocalFilePath)" "Error"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log "Found $($files.Count) file(s) to upload" "Info"
|
||||
$filePathList = @($files | ForEach-Object {$_.FullName})
|
||||
|
||||
# Perform upload
|
||||
$success = Upload-ToSFTP -Server $ServerAddress `
|
||||
-User $Username `
|
||||
-Pass $PasswordParam `
|
||||
-FileList $filePathList `
|
||||
-RemotePath $RemoteFolderPath `
|
||||
-PortNum $Port `
|
||||
-WinSCPExe $WinSCPPath
|
||||
|
||||
Write-Log "========== PROCESS COMPLETED ==========" "Info"
|
||||
|
||||
if ($success) {
|
||||
exit 0
|
||||
}
|
||||
else {
|
||||
exit 1
|
||||
}
|
||||
58
.gitea/workflows/windows.yaml
Normal file
58
.gitea/workflows/windows.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Windows Kernel Build
|
||||
run-name: Build and Upload Windows Kernel
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build Windows
|
||||
#Или если есть коммпит в папку lib в ветке main
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'lib/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Кэш для electron-builder
|
||||
- name: Cache electron-builder
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ env.LOCALAPPDATA }}\\electron-builder\\Cache
|
||||
${{ env.LOCALAPPDATA }}\\electron\\Cache
|
||||
key: ${{ runner.os }}-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-electron-builder-
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: NPM offline setup
|
||||
shell: powershell
|
||||
run: |
|
||||
npm config set cache "$env:LOCALAPPDATA\npm-cache" --global
|
||||
npm config set prefer-offline true --global
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm install --prefer-offline --no-audit --no-fund
|
||||
|
||||
- name: Build the application
|
||||
run: npm run kernel:win
|
||||
|
||||
- name: Upload to SSH using WinSCP Powershell
|
||||
shell: powershell
|
||||
run: |
|
||||
& "$env:GITHUB_WORKSPACE\.gitea\workflows\sshupload.ps1" `
|
||||
-LocalFilePath "dist/builds/win/x64/Rosetta-*.exe" `
|
||||
-RemoteFolderPath "${{ secrets.SDU_SSH_KERNEL }}/win32/x64" `
|
||||
-ServerAddress "${{ secrets.SDU_SSH_HOST }}" `
|
||||
-Username "${{ secrets.SDU_SSH_USERNAME }}" `
|
||||
-PasswordParam '${{ secrets.SDU_SSH_PASSWORD }}'
|
||||
35
app/App.tsx
35
app/App.tsx
@@ -6,9 +6,8 @@ import { ConfirmSeed } from './views/ConfirmSeed/ConfirmSeed';
|
||||
import { SetPassword } from './views/SetPassword/SetPassword';
|
||||
import { Main } from './views/Main/Main';
|
||||
import { ExistsSeed } from './views/ExistsSeed/ExistsSeed';
|
||||
import { Box, Divider } from '@mantine/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import './style.css'
|
||||
import { useRosettaColors } from './hooks/useRosettaColors';
|
||||
import { Buffer } from 'buffer';
|
||||
import { InformationProvider } from './providers/InformationProvider/InformationProvider';
|
||||
import { BlacklistProvider } from './providers/BlacklistProvider/BlacklistProvider';
|
||||
@@ -23,12 +22,11 @@ import { DialogStateProvider } from './providers/DialogStateProvider.tsx/DialogS
|
||||
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
|
||||
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
|
||||
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
|
||||
import { PlayerProvider } from './providers/PlayerProvider/PlayerProvider';
|
||||
window.Buffer = Buffer;
|
||||
|
||||
export default function App() {
|
||||
const { allAccounts, accountProviderLoaded } = useAccountProvider();
|
||||
const colors = useRosettaColors();
|
||||
|
||||
|
||||
const getViewByLoginState = () => {
|
||||
if (!accountProviderLoaded) {
|
||||
@@ -59,22 +57,23 @@ export default function App() {
|
||||
<SystemAccountProvider>
|
||||
<Box h={'100%'}>
|
||||
<Topbar></Topbar>
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
<ContextMenuProvider>
|
||||
<ImageViwerProvider>
|
||||
<AvatarProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
getViewByLoginState()
|
||||
} />
|
||||
<Route path="/create-seed" element={<CreateSeed />} />
|
||||
<Route path="/confirm-seed" element={<ConfirmSeed />} />
|
||||
<Route path="/set-password" element={<SetPassword />} />
|
||||
<Route path="/main/*" element={<Main />} />
|
||||
<Route path="/exists-seed" element={<ExistsSeed />} />
|
||||
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
|
||||
</Routes>
|
||||
</AvatarProvider>
|
||||
<PlayerProvider>
|
||||
<AvatarProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
getViewByLoginState()
|
||||
} />
|
||||
<Route path="/create-seed" element={<CreateSeed />} />
|
||||
<Route path="/confirm-seed" element={<ConfirmSeed />} />
|
||||
<Route path="/set-password" element={<SetPassword />} />
|
||||
<Route path="/main/*" element={<Main />} />
|
||||
<Route path="/exists-seed" element={<ExistsSeed />} />
|
||||
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
|
||||
</Routes>
|
||||
</AvatarProvider>
|
||||
</PlayerProvider>
|
||||
</ImageViwerProvider>
|
||||
</ContextMenuProvider>
|
||||
</Box>
|
||||
|
||||
@@ -60,6 +60,7 @@ export function ActionAvatar(props : ActionAvatarProps) {
|
||||
size={120}
|
||||
radius={120}
|
||||
mx="auto"
|
||||
bg={avatars.length > 0 ? '#fff' : undefined}
|
||||
name={props.title.trim() || props.publicKey}
|
||||
color={'initials'}
|
||||
src={avatars.length > 0 ?
|
||||
|
||||
42
app/components/ActiveCall/ActiveCall.module.css
Normal file
42
app/components/ActiveCall/ActiveCall.module.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.active {
|
||||
background: linear-gradient(90deg,rgba(0, 186, 59, 1) 0%, rgba(0, 194, 81, 1) 50%);
|
||||
background-size: 200% 200%;
|
||||
animation: activeFlow 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes activeFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
filter: saturate(1.15);
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
}
|
||||
|
||||
.connecting {
|
||||
background: linear-gradient(120deg, #ff2d2d, #ff7a00, #ff2d2d);
|
||||
background-size: 220% 220%;
|
||||
animation: connectingFlow 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes connectingFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
filter: saturate(1.15);
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
}
|
||||
/* ...existing code... */
|
||||
98
app/components/ActiveCall/ActiveCall.tsx
Normal file
98
app/components/ActiveCall/ActiveCall.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCalls } from "@/app/providers/CallProvider/useCalls";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { Box, Flex, Loader, Text } from "@mantine/core";
|
||||
import classes from "./ActiveCall.module.css";
|
||||
import { CallState } from "@/app/providers/CallProvider/CallProvider";
|
||||
import { IconMicrophone, IconMicrophoneOff, IconPhoneX, IconVolume, IconVolumeOff } from "@tabler/icons-react";
|
||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||
|
||||
export function ActiveCall() {
|
||||
const {activeCall, callState, duration, muted, sound, close, setMuted, setSound, setShowCallView} = useCalls();
|
||||
const [userInfo] = useUserInformation(activeCall);
|
||||
//const colors = useRosettaColors();
|
||||
|
||||
if(activeCall == ""){
|
||||
return <></>
|
||||
}
|
||||
|
||||
const getConnectingClass = () => {
|
||||
if(callState === CallState.CONNECTING
|
||||
|| callState === CallState.INCOMING
|
||||
|| callState === CallState.KEY_EXCHANGE
|
||||
|| callState === CallState.WEB_RTC_EXCHANGE){
|
||||
return classes.connecting;
|
||||
}
|
||||
if(callState === CallState.ACTIVE){
|
||||
return classes.active;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box py={4} style={{
|
||||
cursor: 'pointer'
|
||||
}} px={10} className={getConnectingClass()} onClick={() => setShowCallView(true)}>
|
||||
<Flex align={'center'} justify={'row'} gap={10}>
|
||||
<Flex w={'100%'} justify={'space-between'} align={'center'}>
|
||||
<Flex>
|
||||
{!muted && (
|
||||
<IconMicrophoneOff style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMuted(true);
|
||||
}} size={16} color={'#fff'}></IconMicrophoneOff>
|
||||
)}
|
||||
{muted && (
|
||||
<IconMicrophone style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMuted(false);
|
||||
}} size={16} color={'#fff'}></IconMicrophone>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex justify={'center'} align={'center'} gap={'xs'}>
|
||||
<Text fw={500} c={'#fff'} style={{
|
||||
userSelect: 'none'
|
||||
}} fz={13}>{userInfo?.title || activeCall}</Text>
|
||||
{callState === CallState.CONNECTING && (
|
||||
<Loader type={'dots'} size={12} color="white"></Loader>
|
||||
)}
|
||||
{callState == CallState.ACTIVE && (
|
||||
<Text fw={500} c={'#ffffff'} style={{
|
||||
userSelect: 'none'
|
||||
}} fz={12}>{translateDurationToTime(duration)}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex gap={'xs'} align={'center'} justify={'center'}>
|
||||
{sound && (
|
||||
<IconVolumeOff style={{
|
||||
cursor: 'pointer'
|
||||
}} size={16} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSound(false)
|
||||
}} color={'#fff'}></IconVolumeOff>
|
||||
)}
|
||||
{!sound && (
|
||||
<IconVolume style={{
|
||||
cursor: 'pointer'
|
||||
}} size={16} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSound(true)
|
||||
}} color={'#fff'}></IconVolume>
|
||||
)}
|
||||
<IconPhoneX style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}} size={16} color={'#fff'}></IconPhoneX>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
app/components/Call/Call.tsx
Normal file
139
app/components/Call/Call.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { CallContextValue, CallState } from "@/app/providers/CallProvider/CallProvider";
|
||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { Avatar, Box, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
|
||||
import { IconChevronLeft, IconMicrophone, IconMicrophoneOff, IconPhone, IconPhoneX, IconQrcode, IconVolume, IconVolumeOff, IconX } from "@tabler/icons-react";
|
||||
import { KeyImage } from "../KeyImage/KeyImage";
|
||||
|
||||
export interface CallProps {
|
||||
context: CallContextValue;
|
||||
}
|
||||
|
||||
export function Call(props: CallProps) {
|
||||
const {
|
||||
activeCall,
|
||||
duration,
|
||||
callState,
|
||||
close,
|
||||
sound,
|
||||
setSound,
|
||||
setMuted,
|
||||
setShowCallView,
|
||||
muted,
|
||||
getKeyCast,
|
||||
accept
|
||||
} = props.context;
|
||||
const [userInfo] = useUserInformation(activeCall);
|
||||
const avatars = useAvatars(activeCall);
|
||||
const colors = useRosettaColors();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Box pos={'absolute'} top={0} left={0} w={'100%'} h={'100vh'} style={{
|
||||
zIndex: 11,
|
||||
background: 'linear-gradient(120deg,#141414 0%, #000000 100%)',
|
||||
}}>
|
||||
<Flex h={'100%'} w={'100vw'} direction={'column'} gap={'lg'} pt={'xl'}>
|
||||
<Flex direction={'row'} w={'100%'} gap={'sm'} align={'center'} justify={'space-between'} p={'sm'}>
|
||||
<Flex style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={() => setShowCallView(false)} justify={'center'} align={'center'}>
|
||||
<IconChevronLeft color="white" size={20}></IconChevronLeft>
|
||||
<Text fw={500} c={'white'}>Back</Text>
|
||||
</Flex>
|
||||
<Flex>
|
||||
<Popover width={300} disabled={getKeyCast() == ''} withArrow>
|
||||
<Popover.Target>
|
||||
<IconQrcode color={getKeyCast() == '' ? 'gray' : 'white'} size={24}></IconQrcode>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={'xs'}>
|
||||
<Flex direction={'row'} align={'center'} gap={'xs'}>
|
||||
<Text maw={300} c={'dimmed'} fz={'xs'}>
|
||||
This call is secured by 256 bit end-to-end encryption. Only you and the recipient can read or listen to the content of this call.
|
||||
</Text>
|
||||
<KeyImage radius={0} colors={[
|
||||
theme.colors.blue[1],
|
||||
theme.colors.blue[2],
|
||||
theme.colors.blue[3],
|
||||
theme.colors.blue[4],
|
||||
theme.colors.blue[5]
|
||||
]} size={80} keyRender={getKeyCast()}></KeyImage>
|
||||
</Flex>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex direction={'column'} mt={'xl'} style={{
|
||||
userSelect: 'none'
|
||||
}} w={'100vw'} gap={'sm'} align={'center'} justify={'center'}>
|
||||
<Avatar size={128} bg={avatars.length > 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} color={'initials'} name={userInfo.title}></Avatar>
|
||||
<Text fz={20} fw={'bold'} c={'#FFF'}>{userInfo.title}</Text>
|
||||
{callState == CallState.ACTIVE && (<Text fz={14} c={'#FFF'}>{translateDurationToTime(duration)}</Text>)}
|
||||
{callState == CallState.CONNECTING && (<Text fz={14} c={'#FFF'}>Connecting...</Text>)}
|
||||
{callState == CallState.INCOMING && (<Text fz={14} c={'#FFF'}>Incoming call...</Text>)}
|
||||
{callState == CallState.KEY_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
|
||||
{callState == CallState.WEB_RTC_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
|
||||
<Flex gap={'xl'} align={'center'} justify={'center'} mt={'xl'}>
|
||||
{(callState == CallState.ACTIVE
|
||||
|| callState == CallState.WEB_RTC_EXCHANGE
|
||||
|| callState == CallState.CONNECTING
|
||||
|| callState == CallState.KEY_EXCHANGE) && (
|
||||
<>
|
||||
<Box w={50} onClick={() => setSound(!sound)} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={sound ? colors.chevrons.active : colors.chevrons.disabled}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
{!sound && <IconVolume size={24} color={'#fff'}></IconVolume>}
|
||||
{sound && <IconVolumeOff size={24} color={'#fff'}></IconVolumeOff>}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box w={50} onClick={() => setMuted(!muted)} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={!muted ? colors.chevrons.active : colors.chevrons.disabled}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
{muted && <IconMicrophone size={24} color={'#fff'}></IconMicrophone>}
|
||||
{!muted && <IconMicrophoneOff size={24} color={'#fff'}></IconMicrophoneOff>}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box w={50} onClick={close} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={colors.error}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
<IconPhoneX size={24} color={'#fff'}></IconPhoneX>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{callState == CallState.INCOMING && (
|
||||
<>
|
||||
{userInfo.title != "Rosetta" && (
|
||||
<Box w={50} onClick={close} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={colors.error}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
<IconX size={24} color={'#fff'}></IconX>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
<Box w={userInfo.title != "Rosetta" ? 50 : 100} onClick={accept} style={{
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer'
|
||||
}} h={userInfo.title != "Rosetta" ? 50 : 100} bg={colors.success}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
<IconPhone size={24} color={'#fff'}></IconPhone>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||
import { Avatar, Box, Divider, Flex, Loader, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { Avatar, Box, Divider, Flex, Loader, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconBookmark, IconLockAccess, IconLockCancel, IconTrashX } from "@tabler/icons-react";
|
||||
import { IconBookmark, IconPhone, IconTrashX } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
@@ -20,6 +19,7 @@ import { ReplyHeader } from "../ReplyHeader/ReplyHeader";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { BackToDialogs } from "../BackToDialogs/BackToDialogs";
|
||||
import { useSystemAccounts } from "@/app/providers/SystemAccountsProvider/useSystemAccounts";
|
||||
import { useCalls } from "@/app/providers/CallProvider/useCalls";
|
||||
|
||||
|
||||
export function ChatHeader() {
|
||||
@@ -29,7 +29,6 @@ export function ChatHeader() {
|
||||
const publicKey = usePublicKey();
|
||||
const {deleteMessages, dialog} = useDialog();
|
||||
const theme = useMantineTheme();
|
||||
const [blocked, blockUser, unblockUser] = useBlacklist(dialog);
|
||||
const [opponent, ___, forceUpdateUserInformation] = useUserInformation(dialog);
|
||||
const [protocolState] = useProtocolState();
|
||||
const [userTypeing, setUserTypeing] = useState(false);
|
||||
@@ -39,6 +38,7 @@ export function ChatHeader() {
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const isSystemAccount = systemAccounts.find((acc) => acc.publicKey == dialog) != undefined;
|
||||
const {call} = useCalls();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,20 +78,6 @@ export function ChatHeader() {
|
||||
});
|
||||
}
|
||||
|
||||
const onClickBlockUser = () => {
|
||||
if(opponent.publicKey != "DELETED"
|
||||
&& opponent.publicKey != publicKey){
|
||||
blockUser();
|
||||
}
|
||||
}
|
||||
|
||||
const onClickUnblockUser = () => {
|
||||
if(opponent.publicKey != "DELETED"
|
||||
&& opponent.publicKey != publicKey){
|
||||
unblockUser();
|
||||
}
|
||||
}
|
||||
|
||||
const onClickProfile = () => {
|
||||
if(opponent.publicKey != "DELETED" && opponent.publicKey != publicKey){
|
||||
navigate("/main/profile/" + opponent.publicKey);
|
||||
@@ -116,7 +102,7 @@ export function ChatHeader() {
|
||||
onClick={onClickProfile}
|
||||
>
|
||||
<IconBookmark stroke={2} size={20}></IconBookmark>
|
||||
</Avatar> : <Avatar onClick={onClickProfile} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
|
||||
</Avatar> : <Avatar onClick={onClickProfile} bg={avatars.length > 0 ? '#fff' : undefined} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
|
||||
}
|
||||
<Flex direction={'column'} onClick={onClickProfile}>
|
||||
<Flex align={'center'} gap={3}>
|
||||
@@ -149,32 +135,18 @@ export function ChatHeader() {
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex h={'100%'} align={'center'} gap={'sm'}>
|
||||
<Tooltip onClick={onClickClearMessages} withArrow position={'bottom'} label={"Clear all messages"}>
|
||||
<IconTrashX
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconTrashX>
|
||||
</Tooltip>
|
||||
{publicKey != opponent.publicKey && !blocked && !isSystemAccount && (
|
||||
<Tooltip onClick={onClickBlockUser} withArrow position={'bottom'} label={"Block user"}>
|
||||
<IconLockCancel
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.red[7]} size={24}
|
||||
>
|
||||
</IconLockCancel>
|
||||
</Tooltip>
|
||||
)}
|
||||
{blocked && !isSystemAccount && (
|
||||
<Tooltip onClick={onClickUnblockUser} withArrow position={'bottom'} label={"Unblock user"}>
|
||||
<IconLockAccess
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.green[7]} size={24}
|
||||
>
|
||||
</IconLockAccess>
|
||||
</Tooltip>
|
||||
{publicKey != opponent.publicKey && !isSystemAccount && (
|
||||
<IconPhone
|
||||
onClick={() => call(dialog)}
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconPhone>
|
||||
)}
|
||||
<IconTrashX
|
||||
onClick={onClickClearMessages}
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconTrashX>
|
||||
</Flex>
|
||||
</Flex>}
|
||||
{replyMessages.messages.length > 0 && !replyMessages.inDialogInput && <ReplyHeader></ReplyHeader>}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useDialogInfo } from "@/app/providers/DialogListProvider/useDialogInfo"
|
||||
import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu";
|
||||
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
|
||||
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
|
||||
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||
|
||||
export interface DialogProps extends DialogRow {
|
||||
onClickDialog: (dialog: string) => void;
|
||||
@@ -51,6 +53,7 @@ export function Dialog(props : DialogProps) {
|
||||
|
||||
const isInCurrentDialog = props.dialog_id == сurrentDialogPublicKeyView;
|
||||
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
|
||||
const [protocolState] = useProtocolState();
|
||||
|
||||
usePacket(0x0B, (packet : PacketTyping) => {
|
||||
if(packet.getFromPublicKey() == opponent && packet.getToPublicKey() == publicKey && !fromMe){
|
||||
@@ -85,7 +88,7 @@ export function Dialog(props : DialogProps) {
|
||||
<IconBookmark stroke={2} size={20}></IconBookmark>
|
||||
</Avatar> :
|
||||
<Box style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Avatar src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} />
|
||||
<Avatar bg={avatars.length > 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} />
|
||||
{userInfo.online == OnlineState.ONLINE && (
|
||||
<Box
|
||||
style={{
|
||||
@@ -153,7 +156,7 @@ export function Dialog(props : DialogProps) {
|
||||
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
|
||||
)}
|
||||
{unreaded > 0 && !lastMessageFromMe && <Badge
|
||||
{unreaded > 0 && !lastMessageFromMe && protocolState != ProtocolState.SYNCHRONIZATION && <Badge
|
||||
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
|
||||
c={isInCurrentDialog ? colors.brandColor : 'white'}
|
||||
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react";
|
||||
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMicrophone, IconMoodSmile, IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
||||
import { base64ImageToBlurhash, filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
|
||||
import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
|
||||
import { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
|
||||
import { DialogAttachment } from "../DialogAttachment/DialogAttachment";
|
||||
import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing";
|
||||
@@ -25,7 +25,8 @@ import { AnimatedButton } from "../AnimatedButton/AnimatedButton";
|
||||
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
|
||||
import { MentionList, Mention } from "../MentionList/MentionList";
|
||||
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
|
||||
|
||||
import { useVoiceMessage } from "./useVoiceMessage";
|
||||
import { VoiceRecorder } from "../VoiceRecorder/VoiceRecorder";
|
||||
|
||||
export function DialogInput() {
|
||||
const colors = useRosettaColors();
|
||||
@@ -47,6 +48,7 @@ export function DialogInput() {
|
||||
const [mentionList, setMentionList] = useState<Mention[]>([]);
|
||||
const mentionHandling = useRef<string>("");
|
||||
const {getDraft, saveDraft} = useDrafts(dialog);
|
||||
const {start, stop, isRecording, duration, waves, getAudioBlob, interpolateCompressWaves} = useVoiceMessage();
|
||||
|
||||
|
||||
const avatars = useAvatars(
|
||||
@@ -60,9 +62,19 @@ export function DialogInput() {
|
||||
useHotkeys([
|
||||
['Esc', () => {
|
||||
setAttachments([]);
|
||||
deselectAllMessages();
|
||||
}]
|
||||
], [], true);
|
||||
|
||||
const hasText = message.trim().length > 0;
|
||||
const showSendIcon = hasText || attachments.length > 0 || isRecording;
|
||||
|
||||
const onMicroClick = () => {
|
||||
if(!isRecording) {
|
||||
start();
|
||||
}
|
||||
};
|
||||
|
||||
const fileDialog = useFileDialog({
|
||||
multiple: false,
|
||||
//naccept: '*',
|
||||
@@ -88,7 +100,11 @@ export function DialogInput() {
|
||||
blob: fileContent,
|
||||
id: generateRandomKey(8),
|
||||
type: AttachmentType.FILE,
|
||||
preview: files[0].size + "::" + files[0].name
|
||||
preview: files[0].size + "::" + files[0].name,
|
||||
transport: {
|
||||
transport_server: "",
|
||||
transport_tag: ""
|
||||
}
|
||||
}]);
|
||||
}
|
||||
});
|
||||
@@ -104,14 +120,26 @@ export function DialogInput() {
|
||||
}, [dialog, editableDivRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
|
||||
/**
|
||||
* У системных аккаунтов нельзя отвечать на сообщения
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(replyMessages.inDialogInput && replyMessages.inDialogInput == dialog){
|
||||
setAttachments([{
|
||||
type: AttachmentType.MESSAGES,
|
||||
id: generateRandomKey(8),
|
||||
blob: JSON.stringify([...replyMessages.messages]),
|
||||
preview: ""
|
||||
preview: "",
|
||||
transport: {
|
||||
transport_server: "",
|
||||
transport_tag: ""
|
||||
}
|
||||
}]);
|
||||
editableDivRef.current.focus();
|
||||
if(editableDivRef.current){
|
||||
editableDivRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, [dialog, replyMessages]);
|
||||
|
||||
@@ -171,8 +199,28 @@ export function DialogInput() {
|
||||
mentionHandling.current = username;
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
if(blocked || (message.trim() == "" && attachments.length <= 0)) {
|
||||
const send = async () => {
|
||||
if(blocked || (message.trim() == "" && attachments.length <= 0 && !isRecording)){
|
||||
return;
|
||||
}
|
||||
if(isRecording){
|
||||
const audioBlob = getAudioBlob();
|
||||
stop();
|
||||
if(!audioBlob){
|
||||
return;
|
||||
}
|
||||
sendMessage("", [
|
||||
{
|
||||
blob: Buffer.from(await audioBlob.arrayBuffer()).toString('hex'),
|
||||
id: generateRandomKey(8),
|
||||
type: AttachmentType.VOICE,
|
||||
preview: duration + "::" + interpolateCompressWaves(35).join(","),
|
||||
transport: {
|
||||
transport_server: "",
|
||||
transport_tag: ""
|
||||
}
|
||||
}
|
||||
]);
|
||||
return;
|
||||
}
|
||||
sendMessage(message, attachments);
|
||||
@@ -208,6 +256,12 @@ export function DialogInput() {
|
||||
}
|
||||
|
||||
const onClickCamera = async () => {
|
||||
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
|
||||
/**
|
||||
* У системных аккаунтов нельзя вызывать вложения
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(avatars.length == 0){
|
||||
return;
|
||||
}
|
||||
@@ -215,9 +269,15 @@ export function DialogInput() {
|
||||
blob: avatars[0].avatar,
|
||||
id: generateRandomKey(8),
|
||||
type: AttachmentType.AVATAR,
|
||||
preview: await base64ImageToBlurhash(avatars[0].avatar)
|
||||
preview: "",
|
||||
transport: {
|
||||
transport_server: "",
|
||||
transport_tag: ""
|
||||
}
|
||||
}]);
|
||||
editableDivRef.current.focus();
|
||||
if(editableDivRef.current){
|
||||
editableDivRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const sendTypeingPacket = () => {
|
||||
@@ -229,6 +289,12 @@ export function DialogInput() {
|
||||
}
|
||||
|
||||
const onPaste = async (event: React.ClipboardEvent) => {
|
||||
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
|
||||
/**
|
||||
* У системных аккаунтов нельзя вызывать вложения
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){
|
||||
return;
|
||||
}
|
||||
@@ -242,14 +308,21 @@ export function DialogInput() {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const base64Image = await imagePrepareForNetworkTransfer(file);
|
||||
const attachmentId = generateRandomKey(8);
|
||||
setAttachments([...attachments, {
|
||||
blob: base64Image,
|
||||
id: generateRandomKey(8),
|
||||
id: attachmentId,
|
||||
type: AttachmentType.IMAGE,
|
||||
preview: await base64ImageToBlurhash(base64Image)
|
||||
preview: "",
|
||||
transport: {
|
||||
transport_server: "",
|
||||
transport_tag: ""
|
||||
}
|
||||
}]);
|
||||
}
|
||||
editableDivRef.current.focus();
|
||||
if(editableDivRef.current){
|
||||
editableDivRef.current.focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -273,11 +346,16 @@ export function DialogInput() {
|
||||
return;
|
||||
}
|
||||
let fileContent = await filePrapareForNetworkTransfer(file);
|
||||
const attachmentId = generateRandomKey(8);
|
||||
setAttachments([...attachments, {
|
||||
blob: fileContent,
|
||||
id: generateRandomKey(8),
|
||||
id: attachmentId,
|
||||
type: AttachmentType.FILE,
|
||||
preview: files[0].size + "::" + files[0].name
|
||||
preview: files[0].size + "::" + files[0].name,
|
||||
transport: {
|
||||
transport_server: "",
|
||||
transport_tag: ""
|
||||
}
|
||||
}]);
|
||||
}
|
||||
|
||||
@@ -318,79 +396,118 @@ export function DialogInput() {
|
||||
{!blocked &&
|
||||
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
|
||||
<Flex w={25} mt={10} justify={'center'}>
|
||||
<Menu width={150} withArrow>
|
||||
<Menu.Target>
|
||||
<IconPaperclip stroke={1.5} style={{
|
||||
cursor: 'pointer'
|
||||
}} size={25} color={colors.chevrons.active}></IconPaperclip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{
|
||||
userSelect: 'none'
|
||||
}}>
|
||||
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
|
||||
<Menu.Item fz={'xs'} fw={500} leftSection={
|
||||
<IconFile size={14}></IconFile>
|
||||
} onClick={onClickPaperclip}>File</Menu.Item>
|
||||
{((avatars.length > 0 && !hasGroup(dialog))
|
||||
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
|
||||
&& <Menu.Item fz={'xs'} fw={500} leftSection={
|
||||
<IconCamera size={14}></IconCamera>
|
||||
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{isRecording && (
|
||||
<IconTrash onClick={stop} style={{
|
||||
cursor: 'pointer'
|
||||
}} color={colors.error} stroke={1.5} size={25}></IconTrash>
|
||||
)}
|
||||
{!isRecording && (
|
||||
<Menu width={150} withArrow>
|
||||
<Menu.Target>
|
||||
<IconPaperclip stroke={1.5} style={{
|
||||
cursor: 'pointer'
|
||||
}} size={25} color={colors.chevrons.active}></IconPaperclip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{
|
||||
userSelect: 'none'
|
||||
}}>
|
||||
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
|
||||
<Menu.Item fz={'xs'} fw={500} leftSection={
|
||||
<IconFile size={14}></IconFile>
|
||||
} onClick={onClickPaperclip}>File</Menu.Item>
|
||||
{((avatars.length > 0 && !hasGroup(dialog))
|
||||
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
|
||||
&& <Menu.Item fz={'xs'} fw={500} leftSection={
|
||||
<IconCamera size={14}></IconCamera>
|
||||
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex
|
||||
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||
align={'center'}
|
||||
>
|
||||
<RichTextInput
|
||||
ref={editableDivRef}
|
||||
style={{
|
||||
border: 0,
|
||||
minHeight: 45,
|
||||
fontSize: 14,
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
outline: 'none',
|
||||
paddingTop: 10,
|
||||
paddingBottom: 8
|
||||
}}
|
||||
placeholder="Type message..."
|
||||
autoFocus
|
||||
//ref={textareaRef}
|
||||
//onPaste={onPaste}
|
||||
//maxLength={2500}
|
||||
//w={'100%'}
|
||||
//h={'100%'}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={setMessage}
|
||||
onPaste={onPaste}
|
||||
|
||||
//dangerouslySetInnerHTML={{__html: message}}
|
||||
></RichTextInput>
|
||||
{!isRecording && <>
|
||||
<RichTextInput
|
||||
ref={editableDivRef}
|
||||
style={{
|
||||
border: 0,
|
||||
minHeight: 45,
|
||||
fontSize: 14,
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
outline: 'none',
|
||||
paddingTop: 10,
|
||||
paddingBottom: 8
|
||||
}}
|
||||
placeholder="Type message..."
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={setMessage}
|
||||
onPaste={onPaste}
|
||||
></RichTextInput>
|
||||
</>}
|
||||
{isRecording && <>
|
||||
<VoiceRecorder duration={duration} waves={waves}></VoiceRecorder>
|
||||
</>}
|
||||
</Flex>
|
||||
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
|
||||
<Popover withArrow>
|
||||
<Popover.Target>
|
||||
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
|
||||
cursor: 'pointer'
|
||||
}}></IconMoodSmile>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
<EmojiPicker
|
||||
onEmojiClick={onEmojiClick}
|
||||
searchDisabled
|
||||
skinTonesDisabled
|
||||
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<IconSend stroke={1.5} color={message.trim() == "" && attachments.length <= 0 ? colors.chevrons.active : colors.brandColor} onClick={send} style={{
|
||||
cursor: 'pointer'
|
||||
}} size={25}></IconSend>
|
||||
{!isRecording && <>
|
||||
<Popover withArrow>
|
||||
<Popover.Target>
|
||||
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
|
||||
cursor: 'pointer'
|
||||
}}></IconMoodSmile>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
<EmojiPicker
|
||||
onEmojiClick={onEmojiClick}
|
||||
searchDisabled
|
||||
skinTonesDisabled
|
||||
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</>}
|
||||
<Box pos="relative" ml={isRecording ? 35 : 0} w={25} h={25}>
|
||||
<Transition mounted={showSendIcon} transition="pop" duration={180} timingFunction="ease">
|
||||
{(styles) => (
|
||||
<IconSend
|
||||
stroke={1.5}
|
||||
color={colors.brandColor}
|
||||
onClick={send}
|
||||
style={{
|
||||
...styles,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
size={25}
|
||||
/>
|
||||
)}
|
||||
</Transition>
|
||||
|
||||
<Transition mounted={!showSendIcon} transition="pop" duration={180} timingFunction="ease">
|
||||
{(styles) => (
|
||||
<IconMicrophone
|
||||
stroke={1.5}
|
||||
color={colors.chevrons.active}
|
||||
onClick={onMicroClick}
|
||||
style={{
|
||||
...styles,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
size={25}
|
||||
/>
|
||||
)}
|
||||
</Transition>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>}
|
||||
{blocked && <Box mih={62} bg={colors.boxColor}>
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import animationData from './lottie.json';
|
||||
import { Box, Flex, Skeleton, Text } from "@mantine/core";
|
||||
import { useDialogsList } from "@/app/providers/DialogListProvider/useDialogsList";
|
||||
import { GroupDialog } from "../GroupDialog/GroupDialog";
|
||||
import React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
interface DialogsListProps {
|
||||
mode: 'all' | 'requests';
|
||||
@@ -13,6 +13,7 @@ interface DialogsListProps {
|
||||
|
||||
export function DialogsList(props : DialogsListProps) {
|
||||
const {dialogs, loadingDialogs} = useDialogsList();
|
||||
const filteredDialogs = dialogs.filter(v => (v.is_request == (props.mode == 'requests')));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -36,21 +37,30 @@ export function DialogsList(props : DialogsListProps) {
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{loadingDialogs === 0 && dialogs.filter(v => (v.is_request == (props.mode == 'requests'))).map((dialog) => (
|
||||
<React.Fragment key={dialog.dialog_id}>
|
||||
{dialog.dialog_id.startsWith('#group:') ? (
|
||||
<GroupDialog
|
||||
onClickDialog={props.onSelectDialog}
|
||||
{...dialog}
|
||||
/>
|
||||
) : (
|
||||
<Dialog
|
||||
onClickDialog={props.onSelectDialog}
|
||||
{...dialog}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<motion.div style={{display: 'flex', flexDirection: 'column'}}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loadingDialogs === 0 && filteredDialogs.map((dialog) => (
|
||||
<motion.div
|
||||
key={dialog.dialog_id}
|
||||
layout
|
||||
initial={false}
|
||||
transition={{ duration: 0.1, ease: 'easeInOut' }}
|
||||
>
|
||||
{dialog.dialog_id.startsWith('#group:') ? (
|
||||
<GroupDialog
|
||||
onClickDialog={props.onSelectDialog}
|
||||
{...dialog}
|
||||
/>
|
||||
) : (
|
||||
<Dialog
|
||||
onClickDialog={props.onSelectDialog}
|
||||
{...dialog}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { DialogsPanelHeader } from '../DialogsPanelHeader/DialogsPanelHeader';
|
||||
import { useDialogsList } from '@/app/providers/DialogListProvider/useDialogsList';
|
||||
import { useVerifyRequest } from '@/app/providers/DeviceProvider/useVerifyRequest';
|
||||
import { DeviceVerify } from '../DeviceVerify/DeviceVerify';
|
||||
import { ActiveCall } from '../ActiveCall/ActiveCall';
|
||||
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
|
||||
|
||||
export function DialogsPanel() {
|
||||
const [dialogsMode, setDialogsMode] = useState<'all' | 'requests'>('all');
|
||||
@@ -18,6 +20,7 @@ export function DialogsPanel() {
|
||||
const colors = useRosettaColors();
|
||||
const navigate = useNavigate();
|
||||
const device = useVerifyRequest();
|
||||
const [viewState] = useViewPanelsState();
|
||||
|
||||
useEffect(() => {
|
||||
((async () => {
|
||||
@@ -52,6 +55,9 @@ export function DialogsPanel() {
|
||||
direction={'column'}
|
||||
justify={'space-between'}
|
||||
>
|
||||
{viewState == ViewPanelsState.DIALOGS_PANEL_ONLY && (
|
||||
<ActiveCall></ActiveCall>
|
||||
)}
|
||||
<Box>
|
||||
<DialogsPanelHeader></DialogsPanelHeader>
|
||||
{device && (
|
||||
|
||||
@@ -7,14 +7,16 @@ import { useHotkeys } from "@mantine/hooks";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { DialogHeaderText } from "../DialogHeaderText/DialogHeaderText";
|
||||
import { useCoreDevice } from "@/app/providers/DeviceProvider/useCoreDevice";
|
||||
|
||||
export function DialogsPanelHeader() {
|
||||
const colors = useRosettaColors();
|
||||
const logout = useLogout();
|
||||
const navigate = useNavigate();
|
||||
const publicKey = usePublicKey();
|
||||
const viewKeys = window.platform == 'darwin' ? '⌘' : 'Ctrl+';
|
||||
const triggerKeys = window.platform == 'darwin' ? 'mod' : 'Ctrl';
|
||||
const {platform} = useCoreDevice();
|
||||
const viewKeys = platform == 'darwin' ? '⌘' : 'Ctrl+';
|
||||
const triggerKeys = platform == 'darwin' ? 'mod' : 'Ctrl';
|
||||
|
||||
useHotkeys([
|
||||
[`${triggerKeys}+L`, () => logout()],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
left: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
z-index: 15;
|
||||
app-region: no-drag;
|
||||
}
|
||||
.close_btn, .minimize_btn, .maximize_btn {
|
||||
|
||||
@@ -19,9 +19,10 @@ export function MentionRow(props : MentionRowProps) {
|
||||
{props.username == 'all' && <Avatar title="@" variant="filled" color={colors.brandColor}>@</Avatar>}
|
||||
{props.username == 'admin' && <Avatar title="@" variant="filled" color={colors.error}>@</Avatar>}
|
||||
{props.username != 'all' && props.username != 'admin' && <Avatar
|
||||
title={props.title}
|
||||
variant="filled"
|
||||
name={props.title}
|
||||
variant="light"
|
||||
color="initials"
|
||||
bg={avatars.length > 0 ? '#fff' : undefined}
|
||||
src={avatars.length > 0 ? avatars[0].avatar : null}
|
||||
></Avatar>}
|
||||
<Flex direction={'column'}>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ErrorBoundaryProvider } from "@/app/providers/ErrorBoundaryProvider/Err
|
||||
import { AttachmentError } from "../AttachmentError/AttachmentError";
|
||||
import { MessageAvatar } from "./MessageAvatar";
|
||||
import { MessageProps } from "../Messages/Message";
|
||||
import { MessageCall } from "./MessageCall";
|
||||
import { MessageVoice } from "./MessageVoice";
|
||||
|
||||
export interface MessageAttachmentsProps {
|
||||
attachments: Attachment[];
|
||||
@@ -51,6 +53,10 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
|
||||
return <MessageFile {...attachProps} key={index}></MessageFile>
|
||||
case AttachmentType.AVATAR:
|
||||
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
||||
case AttachmentType.CALL:
|
||||
return <MessageCall {...attachProps} key={index}></MessageCall>
|
||||
case AttachmentType.VOICE:
|
||||
return <MessageVoice {...attachProps} key={index}></MessageVoice>
|
||||
default:
|
||||
return <AttachmentError key={index}></AttachmentError>;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { AspectRatio, Button, Flex, Paper, Text } from "@mantine/core";
|
||||
import { IconArrowDown } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AttachmentProps } from "./MessageAttachments";
|
||||
import { blurhashToBase64Image } from "@/app/utils/utils";
|
||||
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
|
||||
import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider";
|
||||
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
|
||||
import { PopoverLockIconAvatar } from "../PopoverLockIconAvatar/PopoverLockIconAvatar";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { blurhashToBase64Image } from "@/app/workers/image/image";
|
||||
|
||||
export function MessageAvatar(props: AttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
@@ -19,16 +19,18 @@ export function MessageAvatar(props: AttachmentProps) {
|
||||
download,
|
||||
downloadStatus,
|
||||
getBlob,
|
||||
getPreview} = useAttachment(props.attachment, props.chacha_key_plain);
|
||||
getPreview} = useAttachment(props.attachment, props.parent);
|
||||
const mainRef = useRef<HTMLDivElement>(null);
|
||||
const { open } = useImageViewer();
|
||||
const preview = getPreview();
|
||||
const [blob, setBlob] = useState(props.attachment.blob);
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const [blurhashPreview, setBlurhashPreview] = useState("");
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
constructBlob();
|
||||
constructFromBlurhash();
|
||||
}, [downloadStatus]);
|
||||
|
||||
const constructBlob = async () => {
|
||||
@@ -57,6 +59,12 @@ export function MessageAvatar(props: AttachmentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const constructFromBlurhash = async () => {
|
||||
if (preview.length < 20) return;
|
||||
const blob = await blurhashToBase64Image(preview, 200, 220);
|
||||
setBlurhashPreview(blob);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder p={'sm'}>
|
||||
<Flex gap={'sm'} direction={'row'}>
|
||||
@@ -70,7 +78,8 @@ export function MessageAvatar(props: AttachmentProps) {
|
||||
height: 60,
|
||||
width: 60,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover'
|
||||
objectFit: 'cover',
|
||||
background: '#fff'
|
||||
}} src={blob}></img>)}
|
||||
{downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING && preview.length >= 20 && (
|
||||
<>
|
||||
@@ -79,7 +88,7 @@ export function MessageAvatar(props: AttachmentProps) {
|
||||
height: 60,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover'
|
||||
}} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}></img>
|
||||
}} src={blurhashPreview == "" ? undefined : blurhashPreview}></img>
|
||||
</>
|
||||
)}
|
||||
</AspectRatio>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { IconArrowDown, IconFile, IconX } from "@tabler/icons-react";
|
||||
import { dotCenterIfNeeded, humanFilesize } from "@/app/utils/utils";
|
||||
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
|
||||
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||
import { useCore } from "@/app/hooks/useCore";
|
||||
|
||||
export function MessageFile(props : AttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
@@ -18,7 +19,7 @@ export function MessageFile(props : AttachmentProps) {
|
||||
} =
|
||||
useAttachment(
|
||||
props.attachment,
|
||||
props.chacha_key_plain,
|
||||
props.parent,
|
||||
);
|
||||
const preview = getPreview();
|
||||
const error = downloadStatus == DownloadStatus.ERROR;
|
||||
@@ -27,15 +28,15 @@ export function MessageFile(props : AttachmentProps) {
|
||||
const filetype = filename.split(".")[filename.split(".").length - 1];
|
||||
const isEncrypting = props.delivered == DeliveredMessageState.WAITING && uploadedPercentage <= 0;
|
||||
const isUploading = props.delivered == DeliveredMessageState.WAITING && uploadedPercentage > 0 && uploadedPercentage < 100;
|
||||
const {getDownloadsPath} = useCore();
|
||||
|
||||
const onClick = async () => {
|
||||
if(downloadStatus == DownloadStatus.ERROR){
|
||||
return;
|
||||
}
|
||||
if(downloadStatus == DownloadStatus.DOWNLOADED){
|
||||
//let content = await getBlob();
|
||||
//let buffer = Buffer.from(content.split(",")[1], 'base64');
|
||||
let pathInDownloads = window.downloadsPath + "/Rosetta Downloads/" + filename;
|
||||
const downloadsPath = await getDownloadsPath();
|
||||
let pathInDownloads = downloadsPath + "/Rosetta Downloads/" + filename;
|
||||
//await writeFile(pathInDownloads, buffer, false);
|
||||
window.shell.showItemInFolder(pathInDownloads);
|
||||
return;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||
import { useImageViewer } from "@/app/providers/ImageViewerProvider/useImageViewer";
|
||||
import { AspectRatio, Box, Flex, Overlay, Portal, Text } from "@mantine/core";
|
||||
import { AspectRatio, Box, Flex, Loader, Overlay, Portal, Text } from "@mantine/core";
|
||||
import { IconArrowDown, IconCircleX, IconFlameFilled } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AttachmentProps } from "./MessageAttachments";
|
||||
import { blurhashToBase64Image, isMessageDeliveredByTime } from "@/app/utils/utils";
|
||||
import { isMessageDeliveredByTime } from "@/app/utils/utils";
|
||||
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
|
||||
import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider";
|
||||
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
|
||||
import { blurhashToBase64Image } from "@/app/workers/image/image";
|
||||
|
||||
export function MessageImage(props: AttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
@@ -18,16 +19,20 @@ export function MessageImage(props: AttachmentProps) {
|
||||
download,
|
||||
downloadStatus,
|
||||
getBlob,
|
||||
getPreview} = useAttachment(props.attachment, props.chacha_key_plain);
|
||||
getPreview } = useAttachment(props.attachment, props.parent);
|
||||
const mainRef = useRef<HTMLDivElement>(null);
|
||||
const error = downloadStatus == DownloadStatus.ERROR;
|
||||
const { open } = useImageViewer();
|
||||
const preview = getPreview();
|
||||
const [blob, setBlob] = useState(props.attachment.blob);
|
||||
const [loadedImage, setLoadedImage] = useState(false);
|
||||
const [blurhashPreview, setBlurhashPreview] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
console.info(props.attachment);
|
||||
console.info("Consturcting image, download status: " + downloadStatus);
|
||||
constructBlob();
|
||||
constructFromBlurhash();
|
||||
}, [downloadStatus]);
|
||||
|
||||
const constructBlob = async () => {
|
||||
@@ -45,6 +50,12 @@ export function MessageImage(props: AttachmentProps) {
|
||||
open(images, 0);
|
||||
}
|
||||
|
||||
const constructFromBlurhash = async () => {
|
||||
if (preview.length < 20) return;
|
||||
const blob = await blurhashToBase64Image(preview, 200, 220);
|
||||
setBlurhashPreview(blob);
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||
openImageViewer();
|
||||
@@ -55,7 +66,6 @@ export function MessageImage(props: AttachmentProps) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AspectRatio onClick={onClick} ref={mainRef} style={{
|
||||
minWidth: 200,
|
||||
@@ -76,7 +86,7 @@ export function MessageImage(props: AttachmentProps) {
|
||||
border: '1px solid ' + colors.borderColor,
|
||||
display: loadedImage ? 'block' : 'none'
|
||||
}} src={blob} onLoad={() => setLoadedImage(true)}></img>)}
|
||||
{((downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING) || !loadedImage) && preview.length >= 20 && (
|
||||
{((downloadStatus == DownloadStatus.NOT_DOWNLOADED) || !loadedImage) && preview.length >= 20 && (
|
||||
<>
|
||||
<img style={{
|
||||
minHeight: 220,
|
||||
@@ -84,45 +94,7 @@ export function MessageImage(props: AttachmentProps) {
|
||||
borderRadius: 8,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid ' + colors.borderColor
|
||||
}} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}></img>
|
||||
<Portal target={mainRef.current!}>
|
||||
<Flex direction={'column'} pos={'absolute'} justify={'center'} top={0} h={'100%'} align={'center'} gap={'xs'}>
|
||||
{!error && (
|
||||
<Box style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: 50,
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
{downloadPercentage > 0 ? (
|
||||
<AnimatedRoundedProgress size={40} value={downloadPercentage} color="white"></AnimatedRoundedProgress>
|
||||
) : (
|
||||
<IconArrowDown size={25} color={'white'} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box p={'xs'} style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}>
|
||||
<Text size={'xs'} c={'white'}>
|
||||
Image expired
|
||||
</Text>
|
||||
<IconFlameFilled size={15} style={{
|
||||
fontSmooth: 'always'
|
||||
}} color={'white'} />
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Portal>
|
||||
}} src={blurhashPreview == "" ? undefined : blurhashPreview}></img>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -141,7 +113,43 @@ export function MessageImage(props: AttachmentProps) {
|
||||
<AnimatedRoundedProgress size={40} value={uploadedPercentage > 95 ? 95 : uploadedPercentage}></AnimatedRoundedProgress>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Portal>}
|
||||
</Portal>}
|
||||
{props.delivered == DeliveredMessageState.WAITING && uploadedPercentage == 0 && isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length) &&
|
||||
<Portal target={mainRef.current!}>
|
||||
<Flex direction={'column'} pos={'absolute'} justify={'center'} top={0} h={'100%'} align={'center'} gap={'xs'}>
|
||||
<Box p={'xs'} style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}>
|
||||
<Loader size={15} type={'dots'} color={'white'}></Loader>
|
||||
<Text size={'xs'} c={'white'}>
|
||||
Encrypting...
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Portal>}
|
||||
{downloadStatus == DownloadStatus.DECRYPTING &&
|
||||
<Portal target={mainRef.current!}>
|
||||
<Flex direction={'column'} pos={'absolute'} justify={'center'} top={0} h={'100%'} align={'center'} gap={'xs'}>
|
||||
<Box p={'xs'} style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}>
|
||||
<Loader size={15} type={'dots'} color={'white'}></Loader>
|
||||
<Text size={'xs'} c={'white'}>
|
||||
Decrypting...
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Portal>}
|
||||
{(props.delivered == DeliveredMessageState.ERROR || (props.delivered != DeliveredMessageState.DELIVERED &&
|
||||
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
|
||||
)) && (
|
||||
@@ -149,6 +157,52 @@ export function MessageImage(props: AttachmentProps) {
|
||||
<IconCircleX size={40} color={colors.error} />
|
||||
</Overlay>
|
||||
)}
|
||||
|
||||
{(downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.DOWNLOADING) && (<Flex direction={'column'} pos={'absolute'} top={0} justify={'center'} h={'100%'} align={'center'} gap={'xs'}>
|
||||
{!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',
|
||||
}}>
|
||||
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} color="white"></AnimatedRoundedProgress>
|
||||
</Box>
|
||||
)}
|
||||
{!error && downloadStatus != DownloadStatus.DOWNLOADING && (
|
||||
<Box style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: 50,
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<IconArrowDown size={25} color={'white'} />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box p={'xs'} style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}>
|
||||
<Text size={'xs'} c={'white'}>
|
||||
Image expired
|
||||
</Text>
|
||||
<IconFlameFilled size={15} style={{
|
||||
fontSmooth: 'always'
|
||||
}} color={'white'} />
|
||||
</Box>
|
||||
)}
|
||||
</Flex>)}
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ReplyedMessage } from "../ReplyedMessage/ReplyedMessage";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { MessageReply } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
|
||||
export function MessageReplyMessages(props: AttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
@@ -12,9 +13,7 @@ export function MessageReplyMessages(props: AttachmentProps) {
|
||||
('showAlertInReplyMessages', true);
|
||||
const [bgInReplyMessages] = useSetting<string>
|
||||
('bgInReplyMessages', '');
|
||||
const reply = JSON.parse(props.attachment.blob);
|
||||
|
||||
//console.info("Mreply", reply);
|
||||
const reply = JSON.parse(props.attachment.blob) as MessageReply[];
|
||||
|
||||
const closeAlert = () => {
|
||||
modals.openConfirmModal({
|
||||
@@ -40,8 +39,8 @@ export function MessageReplyMessages(props: AttachmentProps) {
|
||||
{reply.length <= 0 &&
|
||||
<Skeleton h={50} w={'100%'}></Skeleton>
|
||||
}
|
||||
{reply.map((msg, index) => (
|
||||
<ReplyedMessage parent={props.parent} chacha_key_plain={props.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
|
||||
{reply.map((msg : MessageReply, index) => (
|
||||
<ReplyedMessage parent={props.parent} chacha_key_plain={msg.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
|
||||
))}
|
||||
{showAlertInReplyMessages && <Alert style={{
|
||||
borderTopLeftRadius: 0,
|
||||
|
||||
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,
|
||||
message: props.message,
|
||||
attachments: props.attachments.filter(a => a.type != AttachmentType.MESSAGES),
|
||||
message_id: props.message_id
|
||||
message_id: props.message_id,
|
||||
/**
|
||||
* Кодируем в hex чтобы было удобнее передавать по сети
|
||||
*/
|
||||
chacha_key_plain: props.chacha_key_plain
|
||||
};
|
||||
|
||||
const avatars = useAvatars(user.publicKey);
|
||||
@@ -125,6 +129,9 @@ export function Message(props: MessageProps) {
|
||||
if (props.replyed) {
|
||||
return false;
|
||||
}
|
||||
if(props.chacha_key_plain == ""){
|
||||
return false;
|
||||
}
|
||||
if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) {
|
||||
return false;
|
||||
}
|
||||
@@ -186,7 +193,7 @@ export function Message(props: MessageProps) {
|
||||
{computedMessageStyle == MessageStyle.ROWS && (
|
||||
<Flex direction={'row'} justify={'space-between'} gap={'sm'}>
|
||||
<Flex direction={'row'} gap={'sm'}>
|
||||
{(!props.avatar_no_render && (md || !props.replyed)) && <Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>}
|
||||
{(!props.avatar_no_render && (md || !props.replyed)) && <Avatar bg={avatars.length > 0 ? '#fff' : undefined} onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>}
|
||||
<Flex direction={'column'}>
|
||||
<Flex direction={'row'} gap={3} align={'center'}>
|
||||
{!props.avatar_no_render && (
|
||||
@@ -213,7 +220,7 @@ export function Message(props: MessageProps) {
|
||||
userSelect: 'text',
|
||||
fontSize: '13px',
|
||||
color: messageStyle == MessageStyle.BUBBLES ? (computedTheme == 'light' ? (props.parent?.from_me ? 'white' : 'black') : 'white') : (computedTheme == 'light' ? 'black' : 'white')
|
||||
}} ml={props.avatar_no_render ? 50 : undefined}>
|
||||
}} ml={props.avatar_no_render ? 50 : undefined} onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<TextParser performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
|
||||
</Box>
|
||||
</Flex>
|
||||
@@ -262,7 +269,7 @@ export function Message(props: MessageProps) {
|
||||
return (
|
||||
<Flex direction={props.from_me ? 'row-reverse' : 'row'} gap={'sm'} align={'flex-end'}>
|
||||
{(md && props.is_last_message_in_stack) && (
|
||||
<Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar>
|
||||
<Avatar bg={avatars.length > 0 ? '#fff' : undefined} onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar>
|
||||
)}
|
||||
{(md && !props.is_last_message_in_stack) && (
|
||||
<Box style={{ width: 40, height: 40, flexShrink: 0 }}></Box>
|
||||
@@ -302,7 +309,7 @@ export function Message(props: MessageProps) {
|
||||
userSelect: 'text',
|
||||
fontSize: '14px',
|
||||
color: props.from_me ? 'white' : (computedTheme == 'light' ? 'black' : 'white')
|
||||
}}>
|
||||
}} onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<TextParser __reserved_2 performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -7,14 +7,12 @@ import { MessageSkeleton } from "../MessageSkeleton/MessageSkeleton";
|
||||
import { ScrollArea } from "@mantine/core";
|
||||
import { MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S, SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX } from "@/app/constants";
|
||||
import { DialogAffix } from "../DialogAffix/DialogAffix";
|
||||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
||||
|
||||
export function Messages() {
|
||||
const colors = useRosettaColors();
|
||||
const publicKey = usePublicKey();
|
||||
const { messages, dialog, loadMessagesToTop, loading } = useDialog();
|
||||
const { replyMessages, isSelectionStarted } = useReplyMessages();
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastMessageRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -22,6 +20,8 @@ export function Messages() {
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
const isFirstRenderRef = useRef(true);
|
||||
const previousScrollHeightRef = useRef(0);
|
||||
const distanceFromButtomRef = useRef(0);
|
||||
const distanceFromTopRef = useRef(0);
|
||||
|
||||
const [affix, setAffix] = useState(false);
|
||||
const [wallpaper] = useSetting<string>
|
||||
@@ -75,25 +75,25 @@ export function Messages() {
|
||||
return () => observer.disconnect();
|
||||
}, [messages.length, loading]);
|
||||
|
||||
// MutationObserver - отслеживаем изменения контента (загрузка картинок, видео)
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return;
|
||||
if (!contentRef.current || !viewportRef.current) return;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
// Скроллим только если нужен авто-скролл
|
||||
if (shouldAutoScrollRef.current) {
|
||||
const contentEl = contentRef.current;
|
||||
//const viewportEl = viewportRef.current;
|
||||
let lastHeight = contentEl.scrollHeight;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const newHeight = contentEl.scrollHeight;
|
||||
const grew = newHeight > lastHeight;
|
||||
lastHeight = newHeight;
|
||||
|
||||
if (grew && shouldAutoScrollRef.current) {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(contentRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src', 'style', 'class']
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
ro.observe(contentEl);
|
||||
return () => ro.disconnect();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
// Первый рендер - скроллим вниз моментально
|
||||
@@ -121,15 +121,11 @@ export function Messages() {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Скроллим если пользователь внизу или это его собственное сообщение
|
||||
if ((shouldAutoScrollRef.current || lastMessage.from_me) && !affix) {
|
||||
/**
|
||||
* Скролл только если пользователь не читает сейчас старую переписку
|
||||
* (!affix))
|
||||
*/
|
||||
//console.info("Scroll because", shouldAutoScrollRef.current);
|
||||
if ((shouldAutoScrollRef.current || lastMessage.from_me) && distanceFromTopRef.current > 10) {
|
||||
console.info(distanceFromTopRef.current);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
}, [messages.length, loading, affix, scrollToBottom]);
|
||||
}, [messages.length, loading, scrollToBottom]);
|
||||
|
||||
// Восстановление позиции после загрузки старых сообщений
|
||||
useEffect(() => {
|
||||
@@ -142,12 +138,6 @@ export function Messages() {
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Скролл при отправке reply сообщения
|
||||
useEffect(() => {
|
||||
if (replyMessages.messages.length === 0 || isSelectionStarted()) return;
|
||||
scrollToBottom(true);
|
||||
}, [replyMessages.messages.length]);
|
||||
|
||||
const loadMessagesToScrollAreaTop = async () => {
|
||||
if (!viewportRef.current) return;
|
||||
|
||||
@@ -187,6 +177,8 @@ export function Messages() {
|
||||
onScrollPositionChange={(scroll) => {
|
||||
if (!viewportRef.current) return;
|
||||
|
||||
distanceFromTopRef.current = scroll.y;
|
||||
|
||||
// Загружаем старые сообщения при достижении верха
|
||||
if (scroll.y === 0 && !loading && messages.length >= 20) {
|
||||
loadMessagesToScrollAreaTop();
|
||||
@@ -195,6 +187,7 @@ export function Messages() {
|
||||
// Показываем/скрываем кнопку "вниз"
|
||||
const distanceFromBottom =
|
||||
(viewportRef.current.scrollHeight - viewportRef.current.clientHeight) - scroll.y;
|
||||
distanceFromButtomRef.current = distanceFromBottom;
|
||||
|
||||
setAffix(distanceFromBottom > SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX);
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { Button, Flex, Modal, Text } from "@mantine/core";
|
||||
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCornerUpLeft, IconCornerUpRightDouble, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import classes from "./ReplyHeader.module.css";
|
||||
import { DialogsList } from "../DialogsList/DialogsList";
|
||||
@@ -20,10 +20,6 @@ export function ReplyHeader() {
|
||||
const navigate = useNavigate();
|
||||
const {deleteSelectedMessages} = useDialog();
|
||||
|
||||
useHotkeys([
|
||||
['Esc', deselectAllMessages]
|
||||
], [], true);
|
||||
|
||||
const onClickForward = () => {
|
||||
open();
|
||||
}
|
||||
|
||||
@@ -42,6 +42,20 @@ export function TextParser(props: TextParserProps) {
|
||||
const theme = useMantineTheme();
|
||||
let entityCount = 0;
|
||||
|
||||
const UNICODE_EMOJI_SEQUENCE_REGEX =
|
||||
/(?:\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
|
||||
|
||||
const UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL = new RegExp(
|
||||
UNICODE_EMOJI_SEQUENCE_REGEX.source,
|
||||
"gu"
|
||||
);
|
||||
|
||||
const toUnified = (value: string): string =>
|
||||
Array.from(value)
|
||||
.map((ch) => ch.codePointAt(0)?.toString(16))
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
|
||||
const formatRules : FormatRule[] = [
|
||||
{
|
||||
pattern: [
|
||||
@@ -119,6 +133,23 @@ export function TextParser(props: TextParserProps) {
|
||||
return <>{match}</>;
|
||||
}
|
||||
},
|
||||
{
|
||||
// unicode emojis (including composite sequences)
|
||||
pattern: [UNICODE_EMOJI_SEQUENCE_REGEX],
|
||||
render: (match: string) => {
|
||||
const textWithoutEmojis = props.text.replace(UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL, "");
|
||||
const unified = toUnified(match);
|
||||
|
||||
if (textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
|
||||
return <Emoji size={40} unified={unified}></Emoji>;
|
||||
}
|
||||
|
||||
return <Emoji unified={unified}></Emoji>;
|
||||
},
|
||||
flush: (match: string) => {
|
||||
return <Emoji unified={toUnified(match)}></Emoji>;
|
||||
}
|
||||
},
|
||||
{
|
||||
// :emoji_code:
|
||||
pattern: [/:emoji_([a-zA-Z0-9_-]+):/],
|
||||
|
||||
@@ -5,17 +5,19 @@ import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolSt
|
||||
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||
import { WindowsFrameButtons } from "../WindowsFrameButtons/WindowsFrameButtons";
|
||||
import { MacFrameButtons } from "../MacFrameButtons/MacFrameButtons";
|
||||
import { useCoreDevice } from "@/app/providers/DeviceProvider/useCoreDevice";
|
||||
|
||||
export function Topbar() {
|
||||
const colors = useRosettaColors();
|
||||
const [protocolState] = useProtocolState();
|
||||
const {platform} = useCoreDevice();
|
||||
|
||||
|
||||
return (
|
||||
<Box className={classes.drag} ta={'center'} p={3} bg={colors.mainColor}>
|
||||
{window.platform == 'win32' && <WindowsFrameButtons></WindowsFrameButtons>}
|
||||
{window.platform == 'darwin' && <MacFrameButtons></MacFrameButtons>}
|
||||
{window.platform == 'linux' && <WindowsFrameButtons></WindowsFrameButtons>}
|
||||
{platform == 'win32' && <WindowsFrameButtons></WindowsFrameButtons>}
|
||||
{platform == 'darwin' && <MacFrameButtons></MacFrameButtons>}
|
||||
{platform == 'linux' && <WindowsFrameButtons></WindowsFrameButtons>}
|
||||
{(protocolState == ProtocolState.CONNECTED || protocolState == ProtocolState.SYNCHRONIZATION || !window.location.hash.includes("main")) &&
|
||||
<Flex align={'center'} justify={'center'}>
|
||||
<Text fw={'bolder'} fz={13} c={'gray'}>
|
||||
|
||||
@@ -20,7 +20,8 @@ export function UpdateAlert(props : UpdateAlertProps) {
|
||||
updateStatus,
|
||||
downloadLastApplicationUpdate,
|
||||
restartAppForUpdateApply,
|
||||
checkForUpdates
|
||||
checkForUpdates,
|
||||
updateServer
|
||||
} = useUpdater();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,7 +35,7 @@ export function UpdateAlert(props : UpdateAlertProps) {
|
||||
<Button h={45} leftSection={
|
||||
<IconRefresh size={15}/>
|
||||
} onClick={() => {
|
||||
window.shell.openExternal(kernelUpdateUrl);
|
||||
window.shell.openExternal(updateServer + kernelUpdateUrl);
|
||||
}} fullWidth variant={'gradient'} gradient={{ from: 'red', to: 'orange', deg: 233 }} radius={radius}>
|
||||
Kernel update required
|
||||
</Button>
|
||||
|
||||
@@ -40,6 +40,7 @@ export function UserRow(props: UserRowProps) {
|
||||
radius="xl"
|
||||
name={userInfo.title}
|
||||
color={'initials'}
|
||||
bg={avatars.length > 0 ? '#fff' : undefined}
|
||||
src={avatars.length > 0 ? avatars[0].avatar : undefined}
|
||||
/>
|
||||
<Flex direction={'column'}>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import { AttachmentType } from "./providers/ProtocolProvider/protocol/packets/packet.message";
|
||||
|
||||
export const CORE_VERSION = window.version || "1.0.0";
|
||||
|
||||
/**
|
||||
* Application directives
|
||||
*/
|
||||
export const APPLICATION_PLATFROM = window.platform || "unknown";
|
||||
export const APPLICATION_ARCH = window.arch || "unknown";
|
||||
export const APP_PATH = window.appPath || ".";
|
||||
export const SIZE_LOGIN_WIDTH_PX = 300;
|
||||
export const DEVTOOLS_CHEATCODE = "rosettadev1";
|
||||
export const AVATAR_PASSWORD_TO_ENCODE = "rosetta-a";
|
||||
@@ -62,5 +56,8 @@ export const ALLOWED_DOMAINS_ZONES = [
|
||||
'gg',
|
||||
'fm',
|
||||
'tv',
|
||||
'im'
|
||||
'im',
|
||||
'sc',
|
||||
'su',
|
||||
'by'
|
||||
];
|
||||
@@ -1,115 +0,0 @@
|
||||
import { sha256, md5 } from "node-forge";
|
||||
import { generateRandomKey } from "../utils/utils";
|
||||
import * as secp256k1 from '@noble/secp256k1';
|
||||
|
||||
const worker = new Worker(new URL('./crypto.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
export const encodeWithPassword = async (password : string, data : any) : Promise<any> => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, _) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'encodeWithPasswordResult' && event.data.task === task) {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'encodeWithPassword', data: { password, payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const decodeWithPassword = (password : string, data : any) : Promise<any> => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, reject) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'decodeWithPasswordResult' && event.data.task === task) {
|
||||
if(event.data.result === null){
|
||||
reject("Decryption failed");
|
||||
return;
|
||||
}
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'decodeWithPassword', data: { password, payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const generateKeyPairFromSeed = async (seed : string) => {
|
||||
//generate key pair using secp256k1 includes privatekey from seed
|
||||
const privateKey = sha256.create().update(seed).digest().toHex().toString();
|
||||
const publicKey = secp256k1.getPublicKey(Buffer.from(privateKey, "hex"), true);
|
||||
return {
|
||||
privateKey: privateKey,
|
||||
publicKey: Buffer.from(publicKey).toString('hex'),
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const encrypt = async (data : string, publicKey : string) : Promise<any> => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, _) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'encryptResult' && event.data.task === task) {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'encrypt', data: { publicKey, payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const decrypt = async (data : string, privateKey : string) : Promise<any> => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, reject) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'decryptResult' && event.data.task === task) {
|
||||
if(event.data.result === null){
|
||||
reject("Decryption failed");
|
||||
return;
|
||||
}
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'decrypt', data: { privateKey, payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const chacha20Encrypt = async (data : string) => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, _) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'chacha20EncryptResult' && event.data.task === task) {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'chacha20Encrypt', data: { payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const chacha20Decrypt = async (ciphertext : string, nonce : string, key : string) => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, _) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'chacha20DecryptResult' && event.data.task === task) {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'chacha20Decrypt', data: { ciphertext, nonce, key, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const generateMd5 = async (data : string) => {
|
||||
const hash = md5.create();
|
||||
hash.update(data);
|
||||
return hash.digest().toHex();
|
||||
}
|
||||
|
||||
export const generateHashFromPrivateKey = async (privateKey : string) => {
|
||||
return sha256.create().update(privateKey + "rosetta").digest().toHex().toString();
|
||||
}
|
||||
|
||||
export const isEncodedWithPassword = (data : string) => {
|
||||
try{
|
||||
atob(data).split(":");
|
||||
return true;
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.global = window;
|
||||
</script>
|
||||
<script type="module" src="/renderer.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
62
app/hooks/useCore.ts
Normal file
62
app/hooks/useCore.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export function useCore() {
|
||||
const openExternal = (url: string) => {
|
||||
window.shell.openExternal(url);
|
||||
};
|
||||
|
||||
const showItemInFolder = (fullPath: string) => {
|
||||
window.shell.showItemInFolder(fullPath);
|
||||
};
|
||||
|
||||
const getCoreVersion = async () => {
|
||||
const version = await window.electron.ipcRenderer.invoke('ipcCore:getCoreVersion');
|
||||
return version;
|
||||
}
|
||||
|
||||
const getArch = async () => {
|
||||
const arch = await window.electron.ipcRenderer.invoke('ipcCore:getArch');
|
||||
return arch;
|
||||
}
|
||||
|
||||
const getUserDir = async () => {
|
||||
const userDir = await window.electron.ipcRenderer.invoke('ipcCore:getUserDir');
|
||||
return userDir;
|
||||
}
|
||||
|
||||
const getAppPath = async () => {
|
||||
const appPath = await window.electron.ipcRenderer.invoke('ipcCore:getAppPath');
|
||||
return appPath;
|
||||
}
|
||||
|
||||
const getDownloadsPath = async () => {
|
||||
const downloadsPath = await window.electron.ipcRenderer.invoke('ipcCore:getDownloadsPath');
|
||||
return downloadsPath;
|
||||
}
|
||||
|
||||
const getPlatform = async () => {
|
||||
const platform = await window.electron.ipcRenderer.invoke('ipcCore:getPlatform');
|
||||
return platform;
|
||||
}
|
||||
|
||||
const getDeviceName = async () => {
|
||||
const deviceName = await window.electron.ipcRenderer.invoke('device:name');
|
||||
return deviceName;
|
||||
}
|
||||
|
||||
const getDeviceId = async () => {
|
||||
const deviceId = await window.electron.ipcRenderer.invoke('device:id');
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
return {
|
||||
openExternal,
|
||||
showItemInFolder,
|
||||
getCoreVersion,
|
||||
getArch,
|
||||
getUserDir,
|
||||
getAppPath,
|
||||
getDownloadsPath,
|
||||
getPlatform,
|
||||
getDeviceName,
|
||||
getDeviceId
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
const setWindowPriority = (isTop: boolean) => {
|
||||
if(isTop){
|
||||
window.api.invoke('window-top');
|
||||
} else {
|
||||
window.api.invoke('window-priority-normal');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setSize,
|
||||
setResizeble,
|
||||
setTheme
|
||||
setTheme,
|
||||
setWindowPriority
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDownloadStatus } from "../TransportProvider/useDownloadStatus";
|
||||
import { useUploadStatus } from "../TransportProvider/useUploadStatus";
|
||||
import { useFileStorage } from "../../hooks/useFileStorage";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { decodeWithPassword, encodeWithPassword, generateMd5 } from "../../crypto/crypto";
|
||||
import { decodeWithPassword, encodeWithPassword, generateMd5 } from "../../workers/crypto/crypto";
|
||||
import { useTransport } from "../TransportProvider/useTransport";
|
||||
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
|
||||
import { useConsoleLogger } from "../../hooks/useConsoleLogger";
|
||||
import { Attachment, AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { useMemory } from "../MemoryProvider/useMemory";
|
||||
import { DialogContext } from "../DialogProvider/DialogProvider";
|
||||
import { useSaveAvatar } from "../AvatarProvider/useSaveAvatar";
|
||||
import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants";
|
||||
import { useDialog } from "../DialogProvider/useDialog";
|
||||
import { useCore } from "@/app/hooks/useCore";
|
||||
import { MessageProps } from "@/app/components/Messages/Message";
|
||||
import { useGroups } from "../DialogProvider/useGroups";
|
||||
|
||||
export enum DownloadStatus {
|
||||
DOWNLOADED,
|
||||
@@ -24,12 +26,11 @@ export enum DownloadStatus {
|
||||
ERROR
|
||||
}
|
||||
|
||||
export function useAttachment(attachment: Attachment, keyPlain: string) {
|
||||
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}$/;
|
||||
export function useAttachment(attachment: Attachment, parentMessage: MessageProps) {
|
||||
const uploadedPercentage = useUploadStatus(attachment.id);
|
||||
const downloadPercentage = useDownloadStatus(attachment.id);
|
||||
const [downloadStatus, setDownloadStatus] = useMemory("attachment-downloaded-status-" + attachment.id, DownloadStatus.PENDING, true);
|
||||
const [downloadTag, setDownloadTag] = useState("");
|
||||
const [downloadTag, setDownloadTag] = useState(attachment.transport.transport_tag || "");
|
||||
const {readFile, writeFile, fileExists, size} = useFileStorage();
|
||||
const { downloadFile } = useTransport();
|
||||
const publicKey = usePublicKey();
|
||||
@@ -37,13 +38,10 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
|
||||
const {updateAttachmentInDialogCache} = useDialogsCache();
|
||||
const {info} = useConsoleLogger('useAttachment');
|
||||
const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
|
||||
const {getDownloadsPath} = useCore();
|
||||
const {hasGroup} = useGroups();
|
||||
const {dialog} = useDialog();
|
||||
|
||||
|
||||
const context = useContext(DialogContext);
|
||||
if(!context) {
|
||||
throw new Error("useAttachment must be used within a DialogProvider");
|
||||
}
|
||||
const {dialog} = context;
|
||||
const saveAvatar = useSaveAvatar();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,44 +49,38 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
|
||||
}, []);
|
||||
|
||||
const getPreview = () => {
|
||||
if(attachment.preview.split("::")[0].match(uuidRegex)){
|
||||
/**
|
||||
* Это тег загрузки
|
||||
*/
|
||||
return attachment.preview.split("::").splice(1).join("::");
|
||||
}
|
||||
return attachment.preview;
|
||||
}
|
||||
|
||||
const calcDownloadStatus = async () => {
|
||||
if(attachment.preview.split("::")[0].match(uuidRegex)){
|
||||
/**
|
||||
* Это тег загрузки
|
||||
*/
|
||||
setDownloadTag(attachment.preview.split("::")[0]);
|
||||
}
|
||||
if(!attachment.preview.split("::")[0].match(uuidRegex)){
|
||||
/**
|
||||
* Там не тег загрузки, значит это наш файл
|
||||
*/
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
console.info("ds", attachment);
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||
return;
|
||||
}
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||
if(attachment.transport.transport_tag == ""){
|
||||
/**
|
||||
* Транспортного тега нет только у сообщений отправленных нами, значит он точно наш
|
||||
*/
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
return;
|
||||
}
|
||||
if(attachment.type == AttachmentType.FILE){
|
||||
/**
|
||||
* Если это файл, то он хранится не в папке медиа,
|
||||
* а в загрузках
|
||||
* а в загрузках, статус скачивания определяем не только по названию файла,
|
||||
* но и по его размеру (если размеры и название совпало, то считаем файл скаченным)
|
||||
*/
|
||||
const preview = getPreview();
|
||||
const filesize = parseInt(preview.split("::")[0]);
|
||||
const filename = preview.split("::")[1];
|
||||
let pathInDownloads = window.downloadsPath + "/Rosetta Downloads/" + filename;
|
||||
const downloadsPath = await getDownloadsPath();
|
||||
let pathInDownloads = downloadsPath + "/Rosetta Downloads/" + filename;
|
||||
const exists = await fileExists(pathInDownloads, false);
|
||||
const existsLength = await size(pathInDownloads, false);
|
||||
if(exists && existsLength == filesize){
|
||||
/**
|
||||
* Если название файла и его размер совпадают (и он существует), то считаем его скаченным
|
||||
*/
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +135,7 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
|
||||
let downloadedBlob = '';
|
||||
try {
|
||||
downloadedBlob = await downloadFile(attachment.id,
|
||||
downloadTag);
|
||||
downloadTag, attachment.transport.transport_server);
|
||||
} catch (e) {
|
||||
console.info(e);
|
||||
info("Error downloading attachment: " + attachment.id);
|
||||
@@ -151,8 +143,9 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
|
||||
return;
|
||||
}
|
||||
setDownloadStatus(DownloadStatus.DECRYPTING);
|
||||
console.info("decoding with key " + parentMessage.chacha_key_plain);
|
||||
//console.info("Decrypted attachment ", Buffer.from(keyPlain, 'binary').toString('hex'));
|
||||
const decrypted = await decodeWithPassword(keyPlain, downloadedBlob);
|
||||
const decrypted = await decodeWithPassword(parentMessage.chacha_key_plain, downloadedBlob);
|
||||
setDownloadTag("");
|
||||
if(attachment.type == AttachmentType.FILE) {
|
||||
/**
|
||||
@@ -161,8 +154,9 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
|
||||
*/
|
||||
const preview = getPreview();
|
||||
const filename = preview.split("::")[1];
|
||||
const downloadsPath = await getDownloadsPath();
|
||||
let buffer = Buffer.from(decrypted.split(",")[1], 'base64');
|
||||
let pathInDownloads = window.downloadsPath + "/Rosetta Downloads/" + filename;
|
||||
let pathInDownloads = downloadsPath + "/Rosetta Downloads/" + filename;
|
||||
/**
|
||||
* Пишем файл в загрузки, но перед этим выбираем ему название, если файл в загрузках
|
||||
* уже есть с таким названием то добавляем к названию (1), (2) и так далее, чтобы не перезаписать существующий файл
|
||||
@@ -170,7 +164,7 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
|
||||
let finalPath = pathInDownloads;
|
||||
let fileIndex = 1;
|
||||
while (await fileExists(finalPath, false)) {
|
||||
finalPath = window.downloadsPath + "/Rosetta Downloads/" + filename.split(".").slice(0, -1).join(".") + ` (${fileIndex})` + "." + filename.split(".").slice(-1);
|
||||
finalPath = downloadsPath + "/Rosetta Downloads/" + filename.split(".").slice(0, -1).join(".") + ` (${fileIndex})` + "." + filename.split(".").slice(-1);
|
||||
fileIndex++;
|
||||
}
|
||||
await writeFile(finalPath, buffer, false);
|
||||
@@ -185,7 +179,14 @@ export function useAttachment(attachment: Attachment, keyPlain: string) {
|
||||
await writeFile(avatarPath,
|
||||
Buffer.from(await encodeWithPassword(AVATAR_PASSWORD_TO_ENCODE, decrypted)));
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
saveAvatar(dialog, avatarPath, decrypted);
|
||||
/**
|
||||
* Устанавливаем аватарку тому, кто ее прислал.
|
||||
*/
|
||||
let avatarSetTo = parentMessage.from;
|
||||
if(hasGroup(dialog)){
|
||||
avatarSetTo = dialog;
|
||||
}
|
||||
saveAvatar(avatarSetTo, avatarPath, decrypted);
|
||||
return;
|
||||
}
|
||||
/**
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,20 @@ import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { createContext, useEffect, useRef, useState } from "react";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { decodeWithPassword, encodeWithPassword, generateMd5 } from "@/app/crypto/crypto";
|
||||
import { decodeWithPassword, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useSystemAccounts } from "../SystemAccountsProvider/useSystemAccounts";
|
||||
import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants";
|
||||
|
||||
export const AvatarContext = createContext({});
|
||||
export interface AvatarProviderContextValue {
|
||||
deliveredAvatars: string[];
|
||||
saveAvatar: (fromPublicKey: string, path : string, decryptedContent : string) => Promise<void>;
|
||||
loadAvatarsFromCacheByPublicKey: (publicKey : string, allDecode? : boolean) => Promise<void>;
|
||||
changeAvatar: (base64Image : string, entity : string) => Promise<void>;
|
||||
decodedAvatarsCache: AvatarCacheEntry[];
|
||||
}
|
||||
|
||||
export const AvatarContext = createContext<AvatarProviderContextValue | null>(null);
|
||||
|
||||
interface AvatarProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
import { AvatarContext } from "./AvatarProvider";
|
||||
|
||||
export function useSaveAvatar() {
|
||||
export function useSaveAvatar() : (fromPublicKey: string, path : string, decryptedContent : string) => Promise<void> {
|
||||
const context : any = useContext(AvatarContext);
|
||||
if(!context){
|
||||
throw new Error("useSaveAvatar must be used within an AvatarProvider");
|
||||
|
||||
623
app/providers/CallProvider/CallProvider.tsx
Normal file
623
app/providers/CallProvider/CallProvider.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
import { Call } from "@/app/components/Call/Call";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { createContext, useEffect, useRef, useState } from "react";
|
||||
import nacl from 'tweetnacl';
|
||||
import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { PacketSignalPeer, SignalType } from "../ProtocolProvider/protocol/packets/packet.signal.peer";
|
||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||
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 {
|
||||
call: (callable: string) => void;
|
||||
close: () => void;
|
||||
activeCall: string;
|
||||
callState: CallState;
|
||||
muted: boolean;
|
||||
sound: boolean;
|
||||
setMuted: (muted: boolean) => void;
|
||||
setSound: (sound: boolean) => void;
|
||||
duration: number;
|
||||
setShowCallView: (show: boolean) => void;
|
||||
getKeyCast: () => string;
|
||||
accept: () => void;
|
||||
}
|
||||
|
||||
export enum CallState {
|
||||
CONNECTING,
|
||||
KEY_EXCHANGE,
|
||||
/**
|
||||
* Финальная стадия сигналинга, на которой обе стороны обменялись ключами и теперь устанавливают защищенный канал связи для звонка,
|
||||
* через WebRTC, и готовятся к активному звонку.
|
||||
*/
|
||||
WEB_RTC_EXCHANGE,
|
||||
ACTIVE,
|
||||
ENDED,
|
||||
INCOMING
|
||||
}
|
||||
|
||||
export enum CallRole {
|
||||
/**
|
||||
* Вызывающая сторона, которая инициирует звонок
|
||||
*/
|
||||
CALLER,
|
||||
/**
|
||||
* Принимающая сторона, которая отвечает на звонок и принимает его
|
||||
*/
|
||||
CALLEE
|
||||
}
|
||||
|
||||
export const CallContext = createContext<CallContextValue | null>(null);
|
||||
export interface CallProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CallProvider(props : CallProviderProps) {
|
||||
const [activeCall, setActiveCall] = useState<string>("");
|
||||
const [callState, setCallState] = useState<CallState>(CallState.ENDED);
|
||||
const [muted, setMutedState] = useState<boolean>(false);
|
||||
const [sound, setSoundState] = useState<boolean>(true);
|
||||
const durationIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [showCallView, setShowCallView] = useState<boolean>(callState == CallState.INCOMING);
|
||||
const {info} = useConsoleLogger("CallProvider");
|
||||
const [sessionKeys, setSessionKeys] = useState<nacl.BoxKeyPair | null>(null);
|
||||
const send = useSender();
|
||||
const publicKey = usePublicKey();
|
||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||
const roomIdRef = useRef<string>("");
|
||||
|
||||
const roleRef = useRef<CallRole | null>(null);
|
||||
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(() => {
|
||||
if(callState == CallState.ACTIVE){
|
||||
stopLoopSound();
|
||||
stopSound();
|
||||
playSound("connected.mp3");
|
||||
setWindowPriority(false);
|
||||
durationIntervalRef.current = setInterval(() => {
|
||||
setDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
}, [callState]);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Нам нужно получить 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 сигналами, игнорируем
|
||||
*/
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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){
|
||||
/**
|
||||
* Сбросили звонок
|
||||
*/
|
||||
end();
|
||||
return;
|
||||
}
|
||||
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());
|
||||
setCallState(CallState.INCOMING);
|
||||
setShowCallView(true);
|
||||
}
|
||||
if(signalType == SignalType.KEY_EXCHANGE){
|
||||
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
|
||||
/**
|
||||
* Другая сторона отправила нам ключи, теперь отправляем ей свои для генерации общего секрета
|
||||
*/
|
||||
const sharedPublic = packet.getSharedPublic();
|
||||
if(!sharedPublic){
|
||||
info("Received key exchange signal without shared public key");
|
||||
return;
|
||||
}
|
||||
if(!sessionKeys){
|
||||
info("Received key exchange signal but session keys are not generated");
|
||||
return;
|
||||
}
|
||||
|
||||
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
||||
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
||||
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
||||
setCallState(CallState.WEB_RTC_EXCHANGE);
|
||||
|
||||
if(roleRef.current == CallRole.CALLER){
|
||||
/**
|
||||
* Вызывающий уже отправил ключ, сессия сгенерирована, сообщаем серверу что звонок активен
|
||||
*/
|
||||
const activeSignal = new PacketSignalPeer();
|
||||
activeSignal.setSrc(publicKey);
|
||||
activeSignal.setDst(activeCall);
|
||||
activeSignal.setSignalType(SignalType.ACTIVE);
|
||||
send(activeSignal);
|
||||
return;
|
||||
}
|
||||
const signalPacket = new PacketSignalPeer();
|
||||
signalPacket.setSrc(publicKey);
|
||||
signalPacket.setDst(activeCall);
|
||||
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
|
||||
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
|
||||
send(signalPacket);
|
||||
}
|
||||
if(signalType == SignalType.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 sessionKeys = nacl.box.keyPair();
|
||||
info("Generated keys for call session, len: " + sessionKeys.publicKey.length);
|
||||
setSessionKeys(sessionKeys);
|
||||
return sessionKeys;
|
||||
}
|
||||
|
||||
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);
|
||||
setCallState(CallState.CONNECTING);
|
||||
setShowCallView(true);
|
||||
const signalPacket = new PacketSignalPeer();
|
||||
signalPacket.setSrc(publicKey);
|
||||
signalPacket.setDst(dialog);
|
||||
signalPacket.setSignalType(SignalType.CALL);
|
||||
send(signalPacket);
|
||||
roleRef.current = CallRole.CALLER;
|
||||
playSound("calling.mp3", true);
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
const packetSignal = new PacketSignalPeer();
|
||||
packetSignal.setSrc(publicKey);
|
||||
packetSignal.setDst(activeCall);
|
||||
packetSignal.setCallId(callSessionIdRef.current);
|
||||
packetSignal.setJoinToken(callTokenRef.current);
|
||||
packetSignal.setSignalType(SignalType.END_CALL);
|
||||
send(packetSignal);
|
||||
end();
|
||||
}
|
||||
|
||||
const end = () => {
|
||||
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;
|
||||
roomIdRef.current = "";
|
||||
mutedRef.current = false;
|
||||
soundRef.current = true;
|
||||
setActiveCall("");
|
||||
setCallState(CallState.ENDED);
|
||||
setShowCallView(false);
|
||||
setSessionKeys(null);
|
||||
setDuration(0);
|
||||
setMutedState(false);
|
||||
setSoundState(true);
|
||||
stopLoopSound();
|
||||
stopSound();
|
||||
roleRef.current = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет сообщение в диалог с звонящим с информацией о звонке
|
||||
*/
|
||||
const generateCallAttachment = () => {
|
||||
if(roleRef.current != CallRole.CALLER){
|
||||
/**
|
||||
* Только звонящий отправляет информацию о звонке в виде вложения, чтобы ее можно было отобразить в UI диалога, например длительность звонка
|
||||
*/
|
||||
return;
|
||||
}
|
||||
sendMessage(activeCall, "", [{
|
||||
id: generateRandomKey(16),
|
||||
preview: duration.toString(),
|
||||
type: AttachmentType.CALL,
|
||||
transport: {
|
||||
transport_server: "",
|
||||
transport_tag: ""
|
||||
},
|
||||
blob: ""
|
||||
}], true);
|
||||
}
|
||||
|
||||
const accept = () => {
|
||||
if(callState != CallState.INCOMING){
|
||||
/**
|
||||
* Нечего принимать
|
||||
*/
|
||||
return;
|
||||
}
|
||||
setWindowPriority(false);
|
||||
stopLoopSound();
|
||||
stopSound();
|
||||
/**
|
||||
* Звонок принят, генерируем свой ключ для будующего обмена
|
||||
*/
|
||||
generateSessionKeys();
|
||||
/**
|
||||
* Отправляем сигнал что звонок принят другой стороне, чтобы она могла начать обмен ключами и установку соединения
|
||||
*/
|
||||
const signalPacket = new PacketSignalPeer();
|
||||
signalPacket.setSrc(publicKey);
|
||||
signalPacket.setDst(activeCall);
|
||||
signalPacket.setCallId(callSessionIdRef.current);
|
||||
signalPacket.setJoinToken(callTokenRef.current);
|
||||
signalPacket.setSignalType(SignalType.ACCEPT);
|
||||
send(signalPacket);
|
||||
/**
|
||||
* Устанавливаем состояние звонка и стадию обмена ключами
|
||||
*/
|
||||
setCallState(CallState.KEY_EXCHANGE);
|
||||
roleRef.current = CallRole.CALLEE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает слепок ключа для отображения в UI
|
||||
* чтобы не показывать настоящий ключ
|
||||
* @returns
|
||||
*/
|
||||
const getKeyCast = () => {
|
||||
if(!sharedSecretRef.current){
|
||||
return "";
|
||||
}
|
||||
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 = {
|
||||
call,
|
||||
close,
|
||||
activeCall,
|
||||
callState,
|
||||
muted,
|
||||
sound,
|
||||
setMuted,
|
||||
setSound,
|
||||
duration,
|
||||
setShowCallView,
|
||||
getKeyCast,
|
||||
accept
|
||||
};
|
||||
|
||||
return (
|
||||
<CallContext.Provider value={context}>
|
||||
{props.children}
|
||||
<audio ref={remoteAudioRef} autoPlay playsInline style={{ display: 'none' }} />
|
||||
{showCallView && <Call context={context}></Call>}
|
||||
</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));
|
||||
}
|
||||
5
app/providers/CallProvider/translateDurationTime.ts
Normal file
5
app/providers/CallProvider/translateDurationTime.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const translateDurationToTime = (duration: number) => {
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
|
||||
}
|
||||
15
app/providers/CallProvider/useCalls.ts
Normal file
15
app/providers/CallProvider/useCalls.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useContext } from "react";
|
||||
import { CallContext, CallContextValue } from "./CallProvider";
|
||||
|
||||
/**
|
||||
* Хук предоставляет функции для работы с звонками, такие как инициирование звонка, принятие звонка, завершение звонка и т.д.
|
||||
* Он может использоваться в компонентах, связанных с звонками, для управления состоянием звонков и взаимодействия с сервером.
|
||||
*/
|
||||
export function useCalls() : CallContextValue {
|
||||
const context = useContext(CallContext);
|
||||
if (!context) {
|
||||
throw new Error("useCalls must be used within a CallProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
33
app/providers/DeviceProvider/useCoreDevice.ts
Normal file
33
app/providers/DeviceProvider/useCoreDevice.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useCore } from "@/app/hooks/useCore";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useCoreDevice() : {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
platform: string;
|
||||
} {
|
||||
const { getDeviceId, getDeviceName, getPlatform } = useCore();
|
||||
const [deviceId, setDeviceId] = useState<string>("");
|
||||
const [deviceName, setDeviceName] = useState<string>("");
|
||||
const [platform, setPlatform] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeviceInfo();
|
||||
}, []);
|
||||
|
||||
const fetchDeviceInfo = async () => {
|
||||
const deviceId = await getDeviceId();
|
||||
const deviceName = await getDeviceName();
|
||||
const platform = await getPlatform();
|
||||
setDeviceId(deviceId);
|
||||
setDeviceName(deviceName);
|
||||
setPlatform(platform);
|
||||
console.info("Device info - ID:", deviceId, "Name:", deviceName);
|
||||
}
|
||||
|
||||
return {
|
||||
deviceId,
|
||||
deviceName,
|
||||
platform
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { DialogRow } from "./DialogListProvider";
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { decodeWithPassword } from "@/app/crypto/crypto";
|
||||
import { decodeWithPassword } from "@/app/workers/crypto/crypto";
|
||||
import { constructLastMessageTextByAttachments } from "@/app/utils/constructLastMessageTextByAttachments";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { DeliveredMessageState, Message } from "../DialogProvider/DialogProvider";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from '@/app/crypto/crypto';
|
||||
import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto';
|
||||
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
|
||||
import { createContext, useEffect, useRef, useState } from 'react';
|
||||
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
|
||||
import { Attachment, AttachmentTransport, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
|
||||
import { usePrivatePlain } from '../AccountProvider/usePrivatePlain';
|
||||
import { usePublicKey } from '../AccountProvider/usePublicKey';
|
||||
import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read';
|
||||
@@ -11,15 +11,13 @@ import { useBlacklist } from '../BlacklistProvider/useBlacklist';
|
||||
import { useLogger } from '@/app/hooks/useLogger';
|
||||
import { useSender } from '../ProtocolProvider/useSender';
|
||||
import { usePacket } from '../ProtocolProvider/usePacket';
|
||||
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, MESSAGE_MAX_TIME_TO_DELEVERED_S, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
|
||||
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
|
||||
import { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery';
|
||||
import { useIdle } from '@mantine/hooks';
|
||||
import { useWindowFocus } from '@/app/hooks/useWindowFocus';
|
||||
import { useDialogsCache } from './useDialogsCache';
|
||||
import { useConsoleLogger } from '@/app/hooks/useConsoleLogger';
|
||||
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
|
||||
import { MessageReply } from './useReplyMessages';
|
||||
import { useTransport } from '../TransportProvider/useTransport';
|
||||
import { useFileStorage } from '@/app/hooks/useFileStorage';
|
||||
import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
|
||||
import { useDialogsList } from '../DialogListProvider/useDialogsList';
|
||||
@@ -32,7 +30,6 @@ export interface DialogContextValue {
|
||||
setMessages: (messages: React.SetStateAction<Message[]>) => void;
|
||||
dialog: string;
|
||||
clearDialogCache: () => void;
|
||||
prepareAttachmentsToSend: (password: string, attachments: Attachment[]) => Promise<Attachment[]>;
|
||||
loadMessagesToTop: () => Promise<void>;
|
||||
loadMessagesToMessageId: (messageId: string) => Promise<void>;
|
||||
}
|
||||
@@ -49,6 +46,7 @@ export interface AttachmentMeta {
|
||||
id: string;
|
||||
type: AttachmentType;
|
||||
preview: string;
|
||||
transport: AttachmentTransport;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -70,6 +68,23 @@ interface DialogProviderProps {
|
||||
dialog: string;
|
||||
}
|
||||
|
||||
type DialogMessageEvent = {
|
||||
dialogId: string;
|
||||
message: Message;
|
||||
};
|
||||
|
||||
const bus = new EventTarget();
|
||||
|
||||
export const emitDialogMessage = (payload: DialogMessageEvent) => {
|
||||
bus.dispatchEvent(new CustomEvent<DialogMessageEvent>("dialog:message", { detail: payload }));
|
||||
};
|
||||
|
||||
export const onDialogMessage = (handler: (payload: DialogMessageEvent) => void) => {
|
||||
const listener = (e: Event) => handler((e as CustomEvent<DialogMessageEvent>).detail);
|
||||
bus.addEventListener("dialog:message", listener);
|
||||
return () => bus.removeEventListener("dialog:message", listener);
|
||||
};
|
||||
|
||||
export function DialogProvider(props: DialogProviderProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const {allQuery, runQuery} = useDatabase();
|
||||
@@ -87,15 +102,21 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache();
|
||||
const {info, warn, error} = useConsoleLogger('DialogProvider');
|
||||
const [viewState] = useViewPanelsState();
|
||||
const {uploadFile} = useTransport();
|
||||
const {readFile} = useFileStorage();
|
||||
const intervalsRef = useRef<NodeJS.Timeout>(null);
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const {hasGroup, getGroupKey} = useGroups();
|
||||
const {popMention, isMentioned} = useMentions();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onDialogMessage(({ dialogId, message }) => {
|
||||
if (dialogId !== props.dialog) return;
|
||||
setMessages((prev) => [...prev, message]);
|
||||
});
|
||||
return unsub;
|
||||
}, [props.dialog]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentDialogPublicKeyView(props.dialog);
|
||||
return () => {
|
||||
@@ -194,26 +215,40 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
readUpdated = true;
|
||||
}
|
||||
let decryptKey = '';
|
||||
if(message.from_me && message.chacha_key != "" && !message.chacha_key.startsWith("sync:")){
|
||||
/**
|
||||
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key
|
||||
*/
|
||||
try{
|
||||
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key), 'binary').toString('hex');
|
||||
}catch(e) {
|
||||
decryptKey = "";
|
||||
}
|
||||
}
|
||||
if(message.from_me && message.chacha_key != "" && message.chacha_key.startsWith("sync:")){
|
||||
/**
|
||||
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это
|
||||
* сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет,
|
||||
* значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (plain_message)
|
||||
*/
|
||||
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('utf-8');
|
||||
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('hex');
|
||||
}
|
||||
if(hasGroup(props.dialog)){
|
||||
/**
|
||||
* Если это групповое сообщение, то получаем ключ группы
|
||||
*/
|
||||
decryptKey = await getGroupKey(props.dialog);
|
||||
/**
|
||||
* Приводим к HEX так как этого требует формат расшифровки вложений в приложении
|
||||
*/
|
||||
decryptKey = Buffer.from(decryptKey).toString('hex');
|
||||
}
|
||||
if(!message.from_me && !hasGroup(props.dialog)){
|
||||
/**
|
||||
* Если сообщение не от меня и не групповое,
|
||||
* расшифровываем ключ чачи своим приватным ключом
|
||||
*/
|
||||
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
|
||||
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
|
||||
}
|
||||
finalMessages.push({
|
||||
from_public_key: message.from_public_key,
|
||||
@@ -294,7 +329,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
* Обработчик чтения для личных сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet : PacketRead) => {
|
||||
info("Read packet received in dialog provider");
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
if(fromPublicKey == publicKey){
|
||||
/**
|
||||
@@ -308,7 +342,10 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(fromPublicKey != props.dialog && !idle){
|
||||
if(idle){
|
||||
return;
|
||||
}
|
||||
if(fromPublicKey != props.dialog){
|
||||
return;
|
||||
}
|
||||
setMessages((prev) => prev.map((msg) => {
|
||||
@@ -327,7 +364,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
* Обработчик чтения групповых сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet : PacketRead) => {
|
||||
info("Read packet received in dialog provider");
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
if(fromPublicKey == publicKey){
|
||||
/**
|
||||
@@ -342,7 +378,10 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(toPublicKey != props.dialog && !idle){
|
||||
if(idle){
|
||||
return;
|
||||
}
|
||||
if(toPublicKey != props.dialog){
|
||||
return;
|
||||
}
|
||||
setMessages((prev) => prev.map((msg) => {
|
||||
@@ -387,7 +426,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
}, [publicKey]);
|
||||
|
||||
usePacket(0x08, async (packet : PacketDelivery) => {
|
||||
info("Delivery packet received in dialog provider");
|
||||
const fromPublicKey = packet.getToPublicKey();
|
||||
const messageId = packet.getMessageId();
|
||||
if(fromPublicKey != props.dialog){
|
||||
@@ -419,13 +457,18 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
const timestamp = packet.getTimestamp();
|
||||
const messageId = packet.getMessageId();
|
||||
|
||||
|
||||
if(fromPublicKey != publicKey){
|
||||
/**
|
||||
* Игнорируем если это не сообщение от нас
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(hasGroup(toPublicKey)){
|
||||
/**
|
||||
* Есть другой обработчик для синхронизации групп
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(toPublicKey != props.dialog) {
|
||||
/**
|
||||
* Игнорируем если это не сообщение для этого диалога
|
||||
@@ -441,10 +484,8 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
attachments.push({
|
||||
id: attachment.id,
|
||||
preview: attachment.preview,
|
||||
type: attachment.type,
|
||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
|
||||
...attachment,
|
||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
|
||||
});
|
||||
}
|
||||
|
||||
@@ -454,7 +495,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: 0, //сообщение прочитано
|
||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
||||
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||
from_me: 1, //сообщение от нас
|
||||
plain_message: (decryptedContent as string),
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
@@ -465,6 +506,84 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
setMessages((prev) => ([...prev, newMessage]));
|
||||
}, [privatePlain]);
|
||||
|
||||
/**
|
||||
* Обработчик сообщений для синхронизации своих же сообщений в группе
|
||||
*/
|
||||
usePacket(0x06, async (packet: PacketMessage) => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
|
||||
if(fromPublicKey != publicKey){
|
||||
/**
|
||||
* Это не синхронизация, игнорируем ее в этом обработчике
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
if(toPublicKey != props.dialog){
|
||||
/**
|
||||
* Исправление кросс диалогового сообщения
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
if(!hasGroup(props.dialog)){
|
||||
/**
|
||||
* Если это не групповое сообщение, то для него есть
|
||||
* другой обработчик выше
|
||||
*/
|
||||
return;
|
||||
}
|
||||
const content = packet.getContent();
|
||||
const timestamp = packet.getTimestamp();
|
||||
/**
|
||||
* Генерация рандомного ID сообщения по SEED нужна для того,
|
||||
* чтобы сообщение записанное здесь в стек сообщений совпадало
|
||||
* с тем что записывается в БД в файле useDialogFiber.ts
|
||||
*/
|
||||
const messageId = packet.getMessageId();
|
||||
|
||||
const groupKey = await getGroupKey(toPublicKey);
|
||||
if(!groupKey){
|
||||
log("Group key not found for group " + toPublicKey);
|
||||
error("Message dropped because group key not found for group " + toPublicKey);
|
||||
return;
|
||||
}
|
||||
|
||||
let decryptedContent = '';
|
||||
|
||||
try{
|
||||
decryptedContent = await decodeWithPassword(groupKey, content);
|
||||
}catch(e) {
|
||||
decryptedContent = '';
|
||||
}
|
||||
|
||||
let attachments: Attachment[] = [];
|
||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
attachments.push({
|
||||
...attachment,
|
||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
||||
});
|
||||
}
|
||||
|
||||
const newMessage : Message = {
|
||||
from_public_key: fromPublicKey,
|
||||
to_public_key: toPublicKey,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: 0,
|
||||
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||
from_me: 1,
|
||||
plain_message: decryptedContent,
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: attachments
|
||||
};
|
||||
|
||||
setMessages((prev) => ([...prev, newMessage]));
|
||||
}, [messages, idle, props.dialog]);
|
||||
|
||||
/**
|
||||
* Обработчик для личных сообщений
|
||||
*/
|
||||
@@ -519,20 +638,18 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
attachments.push({
|
||||
id: attachment.id,
|
||||
preview: attachment.preview,
|
||||
type: attachment.type,
|
||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
|
||||
...attachment,
|
||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
|
||||
});
|
||||
}
|
||||
|
||||
console.info(attachments);
|
||||
const newMessage : Message = {
|
||||
from_public_key: fromPublicKey,
|
||||
to_public_key: toPublicKey,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: idle ? 0 : 1,
|
||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
||||
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||
plain_message: (decryptedContent as string),
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
@@ -586,7 +703,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
error("Message dropped because group key not found for group " + toPublicKey);
|
||||
return;
|
||||
}
|
||||
info("New group message packet received from " + fromPublicKey);
|
||||
|
||||
let decryptedContent = '';
|
||||
|
||||
@@ -600,9 +716,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
attachments.push({
|
||||
id: attachment.id,
|
||||
preview: attachment.preview,
|
||||
type: attachment.type,
|
||||
...attachment,
|
||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
||||
});
|
||||
}
|
||||
@@ -613,7 +727,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: idle ? 0 : 1,
|
||||
chacha_key: groupKey,
|
||||
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||
plain_message: decryptedContent,
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
@@ -687,7 +801,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
* Если сообщение не от меня и не групповое,
|
||||
* расшифровываем ключ чачи своим приватным ключом
|
||||
*/
|
||||
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
|
||||
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
|
||||
}
|
||||
finalMessages.push({
|
||||
from_public_key: message.from_public_key,
|
||||
@@ -772,7 +886,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
* Если сообщение не от меня и не групповое,
|
||||
* расшифровываем ключ чачи своим приватным ключом
|
||||
*/
|
||||
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
|
||||
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
|
||||
}
|
||||
finalMessages.push({
|
||||
from_public_key: message.from_public_key,
|
||||
@@ -831,6 +945,16 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if(meta.type == AttachmentType.CALL){
|
||||
/**
|
||||
* Если это звонок
|
||||
*/
|
||||
attachments.push({
|
||||
...meta,
|
||||
blob: ""
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`);
|
||||
if(!fileData) {
|
||||
attachments.push({
|
||||
@@ -839,112 +963,26 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const decrypted = await decodeWithPassword(privatePlain, Buffer.from(fileData, 'binary').toString());
|
||||
let blob = "";
|
||||
if(meta.type != AttachmentType.IMAGE){
|
||||
blob = await decodeWithPassword(privatePlain, Buffer.from(fileData, 'binary').toString());
|
||||
}
|
||||
attachments.push({
|
||||
id: meta.id,
|
||||
blob: decrypted,
|
||||
blob: blob,
|
||||
type: meta.type,
|
||||
preview: meta.preview
|
||||
preview: meta.preview,
|
||||
transport: meta.transport
|
||||
});
|
||||
}
|
||||
return attachments;
|
||||
}catch(e) {
|
||||
console.info(e);
|
||||
error("Failed to parse attachments");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет временную метку в сообщении, пока вложения отправляются,
|
||||
* потому что если этого не делать, то сообщение может быть помечено как
|
||||
* не доставленное из-за таймаута доставки
|
||||
* @param attachments Вложения
|
||||
*/
|
||||
const doTimestampUpdateImMessageWhileAttachmentsSend = (attachments : Attachment[]) => {
|
||||
if(intervalsRef.current){
|
||||
clearInterval(intervalsRef.current);
|
||||
}
|
||||
intervalsRef.current = setInterval(() => {
|
||||
//update timestamp in message to keep message marked as error
|
||||
updateDialog(props.dialog);
|
||||
setMessages((prev) => {
|
||||
return prev.map((value) => {
|
||||
if(value.attachments.length <= 0){
|
||||
return value;
|
||||
}
|
||||
if(value.attachments[0].id != attachments[0].id){
|
||||
return value;
|
||||
}
|
||||
runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), value.message_id]);
|
||||
return {
|
||||
...value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
})
|
||||
});
|
||||
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет старый тег если вложения были подготовлены заново
|
||||
* например при пересылке сообщений
|
||||
*/
|
||||
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
|
||||
if(preview.indexOf("::") == -1){
|
||||
return preview;
|
||||
}
|
||||
let parts = preview.split("::");
|
||||
return parts.slice(1).join("::");
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает вложения для отправки. Подготовка
|
||||
* состоит в загрузке файлов на транспортный сервер, мы не делаем
|
||||
* это через WebSocket из-за ограничений по размеру сообщений,
|
||||
* а так же из-за надежности доставки файлов через HTTP
|
||||
* @param attachments Attachments to prepare for sending
|
||||
*/
|
||||
const prepareAttachmentsToSend = async (password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
|
||||
if(attachments.length <= 0){
|
||||
return [];
|
||||
}
|
||||
let prepared : Attachment[] = [];
|
||||
try{
|
||||
for(let i = 0; i < attachments.length; i++){
|
||||
const attachment : Attachment = attachments[i];
|
||||
if(attachment.type == AttachmentType.MESSAGES){
|
||||
let reply : MessageReply[] = JSON.parse(attachment.blob)
|
||||
for(let j = 0; j < reply.length; j++){
|
||||
reply[j].attachments = await prepareAttachmentsToSend(password, reply[j].attachments, true);
|
||||
}
|
||||
prepared.push({
|
||||
...attachment,
|
||||
blob: await encodeWithPassword(password, JSON.stringify(reply))
|
||||
});
|
||||
continue;
|
||||
}
|
||||
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, так как может возникать ситуация, что одно и то же сообщение
|
||||
* может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации
|
||||
@@ -972,7 +1010,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog));
|
||||
},
|
||||
dialog: props.dialog,
|
||||
prepareAttachmentsToSend,
|
||||
loadMessagesToTop,
|
||||
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};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { useDatabase } from "../DatabaseProvider/useDatabase";
|
||||
import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5} from "../../crypto/crypto";
|
||||
import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5} from "../../workers/crypto/crypto";
|
||||
import { AttachmentMeta, DeliveredMessageState, DialogContext, Message } from "./DialogProvider";
|
||||
import { Attachment, AttachmentType, PacketMessage } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
@@ -14,6 +14,7 @@ import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||
import { useGroups } from "./useGroups";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
|
||||
|
||||
export function useDialog() : {
|
||||
messages: Message[];
|
||||
@@ -35,7 +36,6 @@ export function useDialog() : {
|
||||
}
|
||||
const {loading,
|
||||
messages,
|
||||
prepareAttachmentsToSend,
|
||||
clearDialogCache,
|
||||
setMessages,
|
||||
dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
|
||||
@@ -47,6 +47,7 @@ export function useDialog() : {
|
||||
const [protocolState] = useProtocolState();
|
||||
const {hasGroup, getGroupKey} = useGroups();
|
||||
const {warn} = useConsoleLogger('useDialog');
|
||||
const {prepareAttachmentsToSend} = usePrepareAttachment();
|
||||
|
||||
/**
|
||||
* Отправка сообщения в диалог
|
||||
@@ -95,14 +96,13 @@ export function useDialog() : {
|
||||
* же сообщений (смотреть problem_sync.md)
|
||||
*/
|
||||
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
|
||||
|
||||
setMessages((prev : Message[]) => ([...prev, {
|
||||
from_public_key: publicKey,
|
||||
to_public_key: dialog,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
readed: publicKey == dialog ? 1 : 0,
|
||||
chacha_key: "",
|
||||
chacha_key: key.toString('hex'),
|
||||
from_me: 1,
|
||||
plain_message: message,
|
||||
delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
|
||||
@@ -117,7 +117,8 @@ export function useDialog() : {
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview
|
||||
preview: attachment.preview,
|
||||
transport: attachment.transport
|
||||
});
|
||||
if(attachment.type == AttachmentType.FILE){
|
||||
/**
|
||||
@@ -134,7 +135,7 @@ export function useDialog() : {
|
||||
await runQuery(`
|
||||
INSERT INTO messages
|
||||
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
|
||||
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, aesChachaKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
|
||||
protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING
|
||||
), JSON.stringify(attachmentsMeta)]);
|
||||
updateDialog(dialog);
|
||||
@@ -144,9 +145,9 @@ export function useDialog() : {
|
||||
return;
|
||||
}
|
||||
|
||||
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
|
||||
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() == ""){
|
||||
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
||||
updateDialog(dialog);
|
||||
|
||||
@@ -12,8 +12,8 @@ import { useDialogsCache } from "./useDialogsCache";
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from "@/app/crypto/crypto";
|
||||
import { DeliveredMessageState, Message } from "./DialogProvider";
|
||||
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
|
||||
import { AttachmentMeta, DeliveredMessageState, Message } from "./DialogProvider";
|
||||
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
|
||||
import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
@@ -27,6 +27,7 @@ import { useMentions } from "../DialogStateProvider.tsx/useMentions";
|
||||
import { runTaskInQueue } from "./dialogQueue";
|
||||
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||
import { useUpdateSyncTime } from "./useUpdateSyncTime";
|
||||
|
||||
/**
|
||||
* При вызове будет запущен "фоновый" обработчик
|
||||
@@ -53,27 +54,7 @@ export function useDialogFiber() {
|
||||
const [userInfo] = useUserInformation(publicKey);
|
||||
const { pushMention } = useMentions();
|
||||
const [protocolState] = useProtocolState();
|
||||
|
||||
/**
|
||||
* Обновляет время последней синхронизации для аккаунта
|
||||
* @param timestamp время
|
||||
*/
|
||||
const updateSyncTime = async (timestamp: number) => {
|
||||
if(protocolState == ProtocolState.SYNCHRONIZATION){
|
||||
/**
|
||||
* Если сейчас идет синхронизация то чтобы при синхронизации
|
||||
* не создавать нагрузку на базу данных
|
||||
* по постоянному обновлению, обновляем базу один раз - когда
|
||||
* приходит пакет о том что синхронизация закончилась
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(
|
||||
"INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " +
|
||||
"ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?",
|
||||
[publicKey, timestamp, timestamp, publicKey]
|
||||
);
|
||||
};
|
||||
const updateSyncTime = useUpdateSyncTime();
|
||||
|
||||
/**
|
||||
* Лог
|
||||
@@ -82,101 +63,6 @@ export function useDialogFiber() {
|
||||
info("Starting passive fiber for dialog packets");
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Нам приходят сообщения от себя самих же при синхронизации
|
||||
* нужно обрабатывать их особым образом соотвественно
|
||||
*
|
||||
* Метод нужен для синхронизации своих сообщений
|
||||
*/
|
||||
usePacket(0x06, async (packet: PacketMessage) => {
|
||||
runTaskInQueue(async () => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
const aesChachaKey = packet.getAesChachaKey();
|
||||
const content = packet.getContent();
|
||||
const timestamp = packet.getTimestamp();
|
||||
const messageId = packet.getMessageId();
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это не сообщение от нас
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
const chachaKey = await decodeWithPassword(privatePlain, aesChachaKey);
|
||||
const chachaDecryptedKey = Buffer.from(chachaKey, "binary");
|
||||
const key = chachaDecryptedKey.slice(0, 32);
|
||||
const nonce = chachaDecryptedKey.slice(32);
|
||||
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
||||
await updateSyncTime(timestamp);
|
||||
let attachmentsMeta: any[] = [];
|
||||
let messageAttachments: Attachment[] = [];
|
||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
log("Attachment received id " + attachment.id + " type " + attachment.type);
|
||||
|
||||
let nextLength = messageAttachments.push({
|
||||
...attachment,
|
||||
blob: ""
|
||||
});
|
||||
|
||||
if (attachment.type == AttachmentType.MESSAGES) {
|
||||
/**
|
||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
||||
* в последующем скачивании
|
||||
*/
|
||||
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
|
||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||
}
|
||||
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview
|
||||
});
|
||||
}
|
||||
|
||||
const newMessage: Message = {
|
||||
from_public_key: fromPublicKey,
|
||||
to_public_key: toPublicKey,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: 1, //сообщение прочитано
|
||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
||||
from_me: 1, //сообщение от нас
|
||||
plain_message: (decryptedContent as string),
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: messageAttachments
|
||||
};
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [fromPublicKey,
|
||||
toPublicKey,
|
||||
content,
|
||||
timestamp,
|
||||
0, //по умолчанию не прочитаны
|
||||
"sync:" + aesChachaKey,
|
||||
1, //Свои же сообщения всегда от нас
|
||||
await encodeWithPassword(privatePlain, decryptedContent),
|
||||
publicKey,
|
||||
messageId,
|
||||
DeliveredMessageState.DELIVERED,
|
||||
JSON.stringify(attachmentsMeta)]);
|
||||
|
||||
updateDialog(toPublicKey);
|
||||
|
||||
let dialogCache = getDialogCache(toPublicKey);
|
||||
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
|
||||
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [privatePlain, currentDialogPublicKeyView]);
|
||||
|
||||
/**
|
||||
* Обработчик сообщений для группы
|
||||
*/
|
||||
@@ -218,7 +104,7 @@ export function useDialogFiber() {
|
||||
decryptedContent = '';
|
||||
}
|
||||
|
||||
let attachmentsMeta: any[] = [];
|
||||
let attachmentsMeta: AttachmentMeta[] = [];
|
||||
let messageAttachments: Attachment[] = [];
|
||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
@@ -243,7 +129,8 @@ export function useDialogFiber() {
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview
|
||||
preview: attachment.preview,
|
||||
transport: attachment.transport
|
||||
});
|
||||
}
|
||||
|
||||
@@ -253,7 +140,7 @@ export function useDialogFiber() {
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: idle ? 0 : 1,
|
||||
chacha_key: groupKey,
|
||||
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||
plain_message: decryptedContent,
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
@@ -328,7 +215,7 @@ export function useDialogFiber() {
|
||||
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle]);
|
||||
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle, protocolState]);
|
||||
/**
|
||||
* Обработчик личных сообщений
|
||||
*/
|
||||
@@ -375,7 +262,7 @@ export function useDialogFiber() {
|
||||
const nonce = chachaDecryptedKey.slice(32);
|
||||
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
||||
|
||||
let attachmentsMeta: any[] = [];
|
||||
let attachmentsMeta: AttachmentMeta[] = [];
|
||||
let messageAttachments: Attachment[] = [];
|
||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
@@ -391,7 +278,7 @@ export function useDialogFiber() {
|
||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
||||
* в последующем скачивании
|
||||
*/
|
||||
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
|
||||
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob);
|
||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||
@@ -400,7 +287,8 @@ export function useDialogFiber() {
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview
|
||||
preview: attachment.preview,
|
||||
transport: attachment.transport
|
||||
});
|
||||
}
|
||||
|
||||
@@ -410,7 +298,7 @@ export function useDialogFiber() {
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: idle ? 0 : 1,
|
||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
||||
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||
plain_message: (decryptedContent as string),
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
@@ -445,9 +333,9 @@ export function useDialogFiber() {
|
||||
* чтобы когда приходит пачка сообщений с сервера в момент того как
|
||||
* пользователь был неактивен, не слать уведомления по всем этим сообщениям
|
||||
*/
|
||||
if (!muted.includes(fromPublicKey) || protocolState == ProtocolState.SYNCHRONIZATION) {
|
||||
if (!muted.includes(fromPublicKey) && protocolState != ProtocolState.SYNCHRONIZATION) {
|
||||
/**
|
||||
* Если пользователь в муте или сейчас идет синхронизация - не отправляем уведомление
|
||||
* Если пользователь в муте И сейчас не идет синхронизация, то не отправляем уведомление
|
||||
*/
|
||||
notify("New message", "You have a new message");
|
||||
}
|
||||
@@ -457,48 +345,7 @@ export function useDialogFiber() {
|
||||
addOrUpdateDialogCache(fromPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle]);
|
||||
|
||||
/**
|
||||
* Обработчик синхронизации прочтения личных сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet: PacketRead) => {
|
||||
runTaskInQueue(async () => {
|
||||
if (hasGroup(packet.getToPublicKey())) {
|
||||
/**
|
||||
* Если это относится к группам, то игнорируем здесь,
|
||||
* для этого есть отдельный слушатель usePacket ниже
|
||||
*/
|
||||
return;
|
||||
}
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это не синхронизация нашего прочтения
|
||||
*/
|
||||
return;
|
||||
}
|
||||
console.info("PACKED_READ_SYNC");
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`,
|
||||
[toPublicKey, fromPublicKey, publicKey]);
|
||||
|
||||
console.info("updating with params ", [fromPublicKey, toPublicKey, publicKey]);
|
||||
updateDialog(toPublicKey);
|
||||
log("Read sync packet from other device");
|
||||
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
|
||||
if (message.from_public_key == toPublicKey && !message.readed) {
|
||||
console.info("Marking message as read in cache for dialog with " + fromPublicKey);
|
||||
console.info({ fromPublicKey, toPublicKey });
|
||||
return {
|
||||
...message,
|
||||
readed: 1
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}));
|
||||
});
|
||||
}, [updateDialog, publicKey]);
|
||||
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle, protocolState]);
|
||||
|
||||
/**
|
||||
* Обработчик прочтения личных сообщений
|
||||
@@ -522,13 +369,11 @@ export function useDialogFiber() {
|
||||
return;
|
||||
}
|
||||
await updateSyncTime(Date.now());
|
||||
console.info("PACKED_READ_IM");
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`, [toPublicKey, fromPublicKey, publicKey]);
|
||||
console.info("read im with params ", [fromPublicKey, toPublicKey, publicKey]);
|
||||
updateDialog(fromPublicKey);
|
||||
log("Read packet received from " + fromPublicKey + " for " + toPublicKey);
|
||||
|
||||
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
|
||||
if (message.from_public_key == toPublicKey && !message.readed) {
|
||||
if (message.from_public_key == publicKey && !message.readed) {
|
||||
console.info("Marking message as read in cache for dialog with " + fromPublicKey);
|
||||
console.info({ fromPublicKey, toPublicKey });
|
||||
return {
|
||||
@@ -540,20 +385,28 @@ export function useDialogFiber() {
|
||||
}));
|
||||
});
|
||||
}, [updateDialog, publicKey]);
|
||||
|
||||
/**
|
||||
* Обработчик прочтения групповых сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet: PacketRead) => {
|
||||
runTaskInQueue(async () => {
|
||||
if (!hasGroup(packet.getToPublicKey())) {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if (!hasGroup(toPublicKey)) {
|
||||
/**
|
||||
* Если это не относится к группам, то игнорируем здесь,
|
||||
* для этого есть отдельный слушатель usePacket выше
|
||||
*/
|
||||
return;
|
||||
}
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if(fromPublicKey == publicKey){
|
||||
/**
|
||||
* Игнорируем если это наше прочтение
|
||||
* которое получается при синхронизации
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND from_public_key = ? AND account = ?`, [toPublicKey, publicKey, publicKey]);
|
||||
await updateSyncTime(Date.now());
|
||||
updateDialog(toPublicKey);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useDialogsCache() {
|
||||
|
||||
const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => {
|
||||
/**
|
||||
* TODO: Optimize this function to avoid full map if possible
|
||||
* TODO: Оптимизировать чтобы проходил снизу вверх
|
||||
*/
|
||||
let newCache = dialogsCache.map((cache) => {
|
||||
let newMessages = cache.messages.map((message) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { decodeWithPassword, encodeWithPassword } from "@/app/crypto/crypto";
|
||||
import { decodeWithPassword, encodeWithPassword } from "@/app/workers/crypto/crypto";
|
||||
import { generateRandomKey } from "@/app/utils/utils";
|
||||
import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
@@ -162,26 +162,10 @@ export function useGroups() : {
|
||||
const groupId = packet.getGroupId();
|
||||
info(`Creating group with id ${groupId}`);
|
||||
const encryptKey = generateRandomKey(64);
|
||||
const secureKey = await encodeWithPassword(privatePlain, encryptKey);
|
||||
let content = await encodeWithPassword(encryptKey, `$a=Group created`);
|
||||
let plainMessage = await encodeWithPassword(privatePlain, `$a=Group created`);
|
||||
await runQuery(`
|
||||
INSERT INTO groups (account, group_id, title, description, key) VALUES (?, ?, ?, ?, ?)
|
||||
`, [publicKey, groupId, title, description, secureKey]);
|
||||
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, "#group:" + groupId, content, Date.now(), 1, "", 1, plainMessage, publicKey, generateRandomKey(16),
|
||||
DeliveredMessageState.DELIVERED
|
||||
, '[]']);
|
||||
updateDialog("#group:" + groupId);
|
||||
updateGroupInformation({
|
||||
groupId: groupId,
|
||||
title: title,
|
||||
description: description
|
||||
});
|
||||
setLoading(false);
|
||||
navigate(`/main/chat/${prepareForRoute(groupId)}`);
|
||||
/**
|
||||
* После создания группы в нее необходимо зайти, в соотвествии с новым протоколом
|
||||
*/
|
||||
joinGroup(await constructGroupString(groupId, title, encryptKey, description));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,9 +185,11 @@ export function useGroups() : {
|
||||
const groupId = parsed.groupId;
|
||||
const title = parsed.title;
|
||||
const description = parsed.description;
|
||||
const encodedGroupString = await encodeWithPassword(privatePlain, groupString);
|
||||
|
||||
const packet = new PacketGroupJoin();
|
||||
packet.setGroupId(parsed.groupId);
|
||||
packet.setGroupString(encodedGroupString);
|
||||
send(packet);
|
||||
setLoading(true);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useMemory } from "../MemoryProvider/useMemory";
|
||||
import { Attachment } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { DialogContext } from "./DialogProvider";
|
||||
@@ -19,6 +19,7 @@ export interface MessageReply {
|
||||
message: string;
|
||||
attachments: Attachment[];
|
||||
message_id: string;
|
||||
chacha_key_plain: string;
|
||||
}
|
||||
|
||||
export function useReplyMessages() {
|
||||
@@ -35,7 +36,6 @@ export function useReplyMessages() {
|
||||
const {dialog} = context;
|
||||
|
||||
const selectMessage = (message : MessageReply) => {
|
||||
console.info(message);
|
||||
if(replyMessages.publicKey != dialog){
|
||||
/**
|
||||
* Сброс выбора сообщений из другого диалога
|
||||
@@ -54,7 +54,6 @@ export function useReplyMessages() {
|
||||
}
|
||||
replyMessages.messages.push(message);
|
||||
const sortedByTime = replyMessages.messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
setReplyMessages({
|
||||
publicKey: dialog,
|
||||
messages: sortedByTime
|
||||
@@ -106,16 +105,6 @@ export function useReplyMessages() {
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if(replyMessages.publicKey != dialog
|
||||
&& replyMessages.inDialogInput != dialog){
|
||||
/**
|
||||
* Сброс выбора сообщений при смене диалога
|
||||
*/
|
||||
deselectAllMessages();
|
||||
}
|
||||
}, [dialog]);
|
||||
|
||||
return {replyMessages,
|
||||
translateMessagesToDialogInput,
|
||||
isSelectionInCurrentDialog,
|
||||
|
||||
@@ -6,19 +6,55 @@ import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { PacketSync, SyncStatus } from "../ProtocolProvider/protocol/packets/packet.sync";
|
||||
import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||
import { whenFinish } from "./dialogQueue";
|
||||
import { runTaskInQueue, whenFinish } from "./dialogQueue";
|
||||
import { useProtocol } from "../ProtocolProvider/useProtocol";
|
||||
import { PacketGroupJoin } from "../ProtocolProvider/protocol/packets/packet.group.join";
|
||||
import { useGroups } from "./useGroups";
|
||||
import { chacha20Decrypt, decodeWithPassword, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { GroupStatus } from "../ProtocolProvider/protocol/packets/packet.group.invite.info";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
||||
import { useUpdateGroupInformation } from "../InformationProvider/useUpdateGroupInformation";
|
||||
import { useGroupInviteStatus } from "./useGroupInviteStatus";
|
||||
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { useUpdateSyncTime } from "./useUpdateSyncTime";
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { AttachmentMeta, DeliveredMessageState, Message } from "./DialogProvider";
|
||||
import { MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from "@/app/constants";
|
||||
import { useMemory } from "../MemoryProvider/useMemory";
|
||||
import { useDialogsCache } from "./useDialogsCache";
|
||||
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
|
||||
import { useLogger } from "@/app/hooks/useLogger";
|
||||
import { useIdle } from "@mantine/hooks";
|
||||
import { useViewPanelsState } from "@/app/hooks/useViewPanelsState";
|
||||
import { useWindowFocus } from "@/app/hooks/useWindowFocus";
|
||||
|
||||
/**
|
||||
* Хук отвечает за синхронизацию сообщений, запрос синхронизации
|
||||
* при подключении
|
||||
*/
|
||||
export function useSynchronize() {
|
||||
const [_, setProtocolState] = useProtocolState();
|
||||
const [protocolState, setProtocolState] = useProtocolState();
|
||||
const {getQuery, runQuery} = useDatabase();
|
||||
const publicKey = usePublicKey();
|
||||
const send = useSender();
|
||||
const {protocol} = useProtocol();
|
||||
const {parseGroupString, hasGroup, getGroupKey} = useGroups();
|
||||
const privatePlain = usePrivatePlain();
|
||||
const {error, info} = useConsoleLogger('useSynchronize');
|
||||
const log = useLogger('useSynchronize');
|
||||
const {setInviteStatusByGroupId} = useGroupInviteStatus('');
|
||||
const updateGroupInformation = useUpdateGroupInformation();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const idle = useIdle(TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD * 1000);
|
||||
const updateSyncTime = useUpdateSyncTime();
|
||||
const {writeFile} = useFileStorage();
|
||||
const { getDialogCache, addOrUpdateDialogCache } = useDialogsCache();
|
||||
const [currentDialogPublicKeyView, __] = useMemory("current-dialog-public-key-view", "", true);
|
||||
const [viewState] = useViewPanelsState();
|
||||
const focused = useWindowFocus();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if(protocol.handshakeExchangeComplete){
|
||||
@@ -38,15 +74,47 @@ export function useSynchronize() {
|
||||
send(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакет приходит либо при входе в группу (но там используется слушатель once), либо при
|
||||
* синхронизации. В данном случае этот пакет прийдет только при синхронизации
|
||||
*/
|
||||
usePacket(20, async (packet: PacketGroupJoin) => {
|
||||
const decryptedGroupString = await decodeWithPassword(privatePlain, packet.getGroupString());
|
||||
const parsed = await parseGroupString(decryptedGroupString);
|
||||
if(!parsed){
|
||||
error("Received invalid group string, skipping");
|
||||
return;
|
||||
}
|
||||
const groupStatus = packet.getGroupStatus();
|
||||
if(groupStatus != GroupStatus.JOINED){
|
||||
error("Cannot sync group that is not joined, skipping");
|
||||
return;
|
||||
}
|
||||
const secureKey = await encodeWithPassword(privatePlain, parsed.encryptKey);
|
||||
await runQuery(`
|
||||
INSERT INTO groups (account, group_id, title, description, key) VALUES (?, ?, ?, ?, ?)
|
||||
`, [publicKey, parsed.groupId, parsed.title, parsed.description, secureKey]);
|
||||
updateDialog("#group:" + parsed.groupId);
|
||||
setInviteStatusByGroupId(parsed.groupId, GroupStatus.JOINED);
|
||||
updateGroupInformation({
|
||||
groupId: parsed.groupId,
|
||||
title: parsed.title,
|
||||
description: parsed.description
|
||||
});
|
||||
info("Group synchronized " + parsed.groupId);
|
||||
}, [publicKey]);
|
||||
|
||||
usePacket(25, async (packet: PacketSync) => {
|
||||
const status = packet.getStatus();
|
||||
if(status == SyncStatus.BATCH_START){
|
||||
setProtocolState(ProtocolState.SYNCHRONIZATION);
|
||||
}
|
||||
if(status == SyncStatus.BATCH_END){
|
||||
console.info("Batch start");
|
||||
/**
|
||||
* Этот Promise ждет пока все сообщения синхронизируются и обработаются, только
|
||||
* после этого
|
||||
*/
|
||||
await whenFinish();
|
||||
console.info("Batch finished");
|
||||
await runQuery(
|
||||
"INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " +
|
||||
"ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?",
|
||||
@@ -62,4 +130,295 @@ export function useSynchronize() {
|
||||
setProtocolState(ProtocolState.CONNECTED);
|
||||
}
|
||||
}, [publicKey]);
|
||||
|
||||
/**
|
||||
* Нам приходят сообщения от себя самих же при синхронизации
|
||||
* нужно обрабатывать их особым образом соотвественно
|
||||
*
|
||||
* Метод нужен для синхронизации своих сообщений
|
||||
*/
|
||||
usePacket(0x06, async (packet: PacketMessage) => {
|
||||
runTaskInQueue(async () => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
const aesChachaKey = packet.getAesChachaKey();
|
||||
const content = packet.getContent();
|
||||
const timestamp = packet.getTimestamp();
|
||||
const messageId = packet.getMessageId();
|
||||
if(hasGroup(toPublicKey)){
|
||||
/**
|
||||
* Игнорируем если это сообщение для группы, для них есть отдельный слушатель usePacket ниже
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это не сообщение от нас
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
const chachaKey = await decodeWithPassword(privatePlain, aesChachaKey);
|
||||
const chachaDecryptedKey = Buffer.from(chachaKey, "binary");
|
||||
const key = chachaDecryptedKey.slice(0, 32);
|
||||
const nonce = chachaDecryptedKey.slice(32);
|
||||
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
||||
await updateSyncTime(timestamp);
|
||||
let attachmentsMeta: AttachmentMeta[] = [];
|
||||
let messageAttachments: Attachment[] = [];
|
||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
|
||||
|
||||
let nextLength = messageAttachments.push({
|
||||
...attachment,
|
||||
blob: ""
|
||||
});
|
||||
|
||||
if (attachment.type == AttachmentType.MESSAGES) {
|
||||
/**
|
||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
||||
* в последующем скачивании
|
||||
*/
|
||||
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob);
|
||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||
}
|
||||
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview,
|
||||
transport: attachment.transport
|
||||
});
|
||||
}
|
||||
|
||||
const newMessage: Message = {
|
||||
from_public_key: fromPublicKey,
|
||||
to_public_key: toPublicKey,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: 1, //сообщение прочитано
|
||||
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||
from_me: 1, //сообщение от нас
|
||||
plain_message: (decryptedContent as string),
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: messageAttachments
|
||||
};
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [fromPublicKey,
|
||||
toPublicKey,
|
||||
content,
|
||||
timestamp,
|
||||
0, //по умолчанию не прочитаны
|
||||
"sync:" + aesChachaKey,
|
||||
1, //Свои же сообщения всегда от нас
|
||||
await encodeWithPassword(privatePlain, decryptedContent),
|
||||
publicKey,
|
||||
messageId,
|
||||
DeliveredMessageState.DELIVERED,
|
||||
JSON.stringify(attachmentsMeta)]);
|
||||
|
||||
updateDialog(toPublicKey);
|
||||
|
||||
let dialogCache = getDialogCache(toPublicKey);
|
||||
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
|
||||
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [privatePlain, currentDialogPublicKeyView]);
|
||||
|
||||
/**
|
||||
* Обработчик синхронизации прочтения личных сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet: PacketRead) => {
|
||||
runTaskInQueue(async () => {
|
||||
if (hasGroup(packet.getToPublicKey())) {
|
||||
/**
|
||||
* Если это относится к группам, то игнорируем здесь,
|
||||
* для этого есть отдельный слушатель usePacket ниже
|
||||
*/
|
||||
return;
|
||||
}
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это не синхронизация нашего прочтения
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`,
|
||||
[toPublicKey, fromPublicKey, publicKey]);
|
||||
|
||||
updateDialog(toPublicKey);
|
||||
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
|
||||
if (message.from_public_key == toPublicKey && !message.readed) {
|
||||
console.info({ fromPublicKey, toPublicKey });
|
||||
return {
|
||||
...message,
|
||||
readed: 1
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}));
|
||||
});
|
||||
}, [updateDialog, publicKey]);
|
||||
|
||||
/**
|
||||
* Обработчик синхронизации прочтения групповых сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet: PacketRead) => {
|
||||
runTaskInQueue(async () => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if (!hasGroup(toPublicKey)) {
|
||||
/**
|
||||
* Если это не относится к группам, то игнорируем здесь,
|
||||
* для этого есть отдельный слушатель usePacket выше
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(fromPublicKey != publicKey){
|
||||
/**
|
||||
* Игнорируем если это наше прочтение
|
||||
* которое получается при синхронизации
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND from_public_key != ? AND account = ?`,
|
||||
[toPublicKey, publicKey, publicKey]);
|
||||
await updateSyncTime(Date.now());
|
||||
updateDialog(toPublicKey);
|
||||
addOrUpdateDialogCache(toPublicKey, getDialogCache(toPublicKey).map((message) => {
|
||||
if (!message.readed && message.from_public_key != publicKey) {
|
||||
return {
|
||||
...message,
|
||||
readed: 1
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}));
|
||||
});
|
||||
}, [updateDialog]);
|
||||
|
||||
/**
|
||||
* Обработчик сообщений для синхронизации своих же сообщений в группе
|
||||
*/
|
||||
usePacket(0x06, async (packet: PacketMessage) => {
|
||||
runTaskInQueue(async () => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
const content = packet.getContent();
|
||||
const timestamp = packet.getTimestamp();
|
||||
const messageId = packet.getMessageId();
|
||||
if (!hasGroup(toPublicKey)) {
|
||||
/**
|
||||
* Если это личное сообщение, то игнорируем его здесь
|
||||
* для него есть отдельный слушатель usePacket (снизу)
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это сообщения не от нас
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await updateSyncTime(timestamp);
|
||||
const groupKey = await getGroupKey(toPublicKey);
|
||||
if (!groupKey) {
|
||||
log("Group key not found for group " + toPublicKey);
|
||||
error("Message dropped because group key not found for group " + toPublicKey);
|
||||
return;
|
||||
}
|
||||
info("New group message packet received from " + fromPublicKey);
|
||||
|
||||
let decryptedContent = '';
|
||||
|
||||
try {
|
||||
decryptedContent = await decodeWithPassword(groupKey, content);
|
||||
} catch (e) {
|
||||
decryptedContent = '';
|
||||
}
|
||||
|
||||
let attachmentsMeta: AttachmentMeta[] = [];
|
||||
let messageAttachments: Attachment[] = [];
|
||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
log("Attachment received id " + attachment.id + " type " + attachment.type);
|
||||
|
||||
let nextLength = messageAttachments.push({
|
||||
...attachment,
|
||||
blob: ""
|
||||
});
|
||||
|
||||
if (attachment.type == AttachmentType.MESSAGES) {
|
||||
/**
|
||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
||||
* в последующем скачивании
|
||||
*/
|
||||
const decryptedBlob = await decodeWithPassword(groupKey, attachment.blob);
|
||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||
}
|
||||
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview,
|
||||
transport: attachment.transport
|
||||
});
|
||||
}
|
||||
|
||||
const newMessage: Message = {
|
||||
from_public_key: fromPublicKey,
|
||||
to_public_key: toPublicKey,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: 0,
|
||||
chacha_key: groupKey,
|
||||
from_me: 1,
|
||||
plain_message: decryptedContent,
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: messageAttachments
|
||||
};
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [fromPublicKey,
|
||||
toPublicKey,
|
||||
content,
|
||||
timestamp,
|
||||
0, //по умолчанию не прочитаны
|
||||
"",
|
||||
1, //Свои же сообщения всегда от нас
|
||||
await encodeWithPassword(privatePlain, decryptedContent),
|
||||
publicKey,
|
||||
messageId,
|
||||
DeliveredMessageState.DELIVERED,
|
||||
JSON.stringify(attachmentsMeta)]);
|
||||
|
||||
/**
|
||||
* Так как у нас в toPublicKey приходит ID группы,
|
||||
* то обновляем диалог по этому ID, а не по fromPublicKey
|
||||
* как это сделано в личных сообщениях
|
||||
*/
|
||||
updateDialog(toPublicKey);
|
||||
|
||||
let dialogCache = getDialogCache(toPublicKey);
|
||||
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
|
||||
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [updateDialog, focused, currentDialogPublicKeyView, viewState, idle, protocolState]);
|
||||
}
|
||||
33
app/providers/DialogProvider/useUpdateSyncTime.ts
Normal file
33
app/providers/DialogProvider/useUpdateSyncTime.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { useDatabase } from "../DatabaseProvider/useDatabase";
|
||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
||||
|
||||
export function useUpdateSyncTime() : (timestamp: number) => Promise<void> {
|
||||
const [protocolState] = useProtocolState();
|
||||
const {runQuery} = useDatabase();
|
||||
const publicKey = usePublicKey();
|
||||
|
||||
/**
|
||||
* Обновляет время последней синхронизации для аккаунта
|
||||
* @param timestamp время
|
||||
*/
|
||||
const updateSyncTime = async (timestamp: number) => {
|
||||
if(protocolState == ProtocolState.SYNCHRONIZATION){
|
||||
/**
|
||||
* Если сейчас идет синхронизация то чтобы при синхронизации
|
||||
* не создавать нагрузку на базу данных
|
||||
* по постоянному обновлению, обновляем базу один раз - когда
|
||||
* приходит пакет о том что синхронизация закончилась
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(
|
||||
"INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " +
|
||||
"ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?",
|
||||
[publicKey, timestamp, timestamp, publicKey]
|
||||
);
|
||||
};
|
||||
|
||||
return updateSyncTime;
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export function ImageViewer(props : ImageViewerProps) {
|
||||
// Wheel zoom (zoom to cursor)
|
||||
const onWheel = (e: React.WheelEvent<HTMLImageElement>) => {
|
||||
//e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
@@ -148,6 +149,7 @@ export function ImageViewer(props : ImageViewerProps) {
|
||||
userSelect: 'none',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
transformOrigin: '0 0',
|
||||
background: '#FFF',
|
||||
transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`,
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -12,6 +12,14 @@ export class PacketGroupJoin extends Packet {
|
||||
|
||||
private groupId: string = "";
|
||||
private groupStatus: GroupStatus = GroupStatus.NOT_JOINED;
|
||||
/**
|
||||
* Строка группы, которая содержит информацию о группе, такую как ее название, описание и ключ
|
||||
* Строка зашифрована обратимым шифрованием, где ключом выступает - реальный приватный ключ
|
||||
* входящего в группу клиента. Нужно это для будущей синхронзации, так как клиенту на его другом
|
||||
* устройстве нужно получить ключ группы и ее информацию. Сервер расшифровать эту строку не может. Эту
|
||||
* строку может расшифровать только клиент, так как она зашифрована его приватным ключом
|
||||
*/
|
||||
private groupString: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x14;
|
||||
@@ -20,6 +28,7 @@ export class PacketGroupJoin extends Packet {
|
||||
public _receive(stream: Stream): void {
|
||||
this.groupId = stream.readString();
|
||||
this.groupStatus = stream.readInt8();
|
||||
this.groupString = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
@@ -27,6 +36,7 @@ export class PacketGroupJoin extends Packet {
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.groupId);
|
||||
stream.writeInt8(this.groupStatus);
|
||||
stream.writeString(this.groupString);
|
||||
return stream;
|
||||
}
|
||||
|
||||
@@ -46,4 +56,12 @@ export class PacketGroupJoin extends Packet {
|
||||
return this.groupStatus;
|
||||
}
|
||||
|
||||
public setGroupString(groupString: string) {
|
||||
this.groupString = groupString;
|
||||
}
|
||||
|
||||
public getGroupString(): string {
|
||||
return this.groupString;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
MESSAGES = 1,
|
||||
FILE = 2,
|
||||
AVATAR = 3
|
||||
AVATAR = 3,
|
||||
CALL = 4,
|
||||
VOICE = 5
|
||||
}
|
||||
|
||||
/**
|
||||
* Информация о транспортировке вложения, нужна для загрузки и скачивания вложений с транспортного сервера
|
||||
*/
|
||||
export interface AttachmentTransport {
|
||||
transport_tag: string;
|
||||
transport_server: string;
|
||||
}
|
||||
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
blob: string;
|
||||
type: AttachmentType;
|
||||
preview: string;
|
||||
transport: AttachmentTransport;
|
||||
}
|
||||
|
||||
export class PacketMessage extends Packet {
|
||||
@@ -41,7 +53,7 @@ export class PacketMessage extends Packet {
|
||||
this.toPublicKey = stream.readString();
|
||||
this.content = stream.readString();
|
||||
this.chachaKey = stream.readString();
|
||||
this.timestamp = stream.readInt64();
|
||||
this.timestamp = Number(stream.readInt64());
|
||||
this.privateKey = stream.readString();
|
||||
this.messageId = stream.readString();
|
||||
let attachmentsCount = stream.readInt8();
|
||||
@@ -50,7 +62,11 @@ export class PacketMessage extends Packet {
|
||||
let preview = stream.readString();
|
||||
let blob = stream.readString();
|
||||
let type = stream.readInt8() as AttachmentType;
|
||||
this.attachments.push({id, preview, type, blob});
|
||||
const transport : AttachmentTransport = {
|
||||
transport_tag: stream.readString(),
|
||||
transport_server: stream.readString()
|
||||
}
|
||||
this.attachments.push({id, preview, type, blob, transport});
|
||||
}
|
||||
this.aesChachaKey = stream.readString();
|
||||
}
|
||||
@@ -62,7 +78,7 @@ export class PacketMessage extends Packet {
|
||||
stream.writeString(this.toPublicKey);
|
||||
stream.writeString(this.content);
|
||||
stream.writeString(this.chachaKey);
|
||||
stream.writeInt64(this.timestamp);
|
||||
stream.writeInt64(BigInt(this.timestamp));
|
||||
stream.writeString(this.privateKey);
|
||||
stream.writeString(this.messageId);
|
||||
stream.writeInt8(this.attachments.length);
|
||||
@@ -71,6 +87,8 @@ export class PacketMessage extends Packet {
|
||||
stream.writeString(this.attachments[i].preview);
|
||||
stream.writeString(this.attachments[i].blob);
|
||||
stream.writeInt8(this.attachments[i].type);
|
||||
stream.writeString(this.attachments[i].transport.transport_tag);
|
||||
stream.writeString(this.attachments[i].transport.transport_server);
|
||||
}
|
||||
stream.writeString(this.aesChachaKey);
|
||||
return stream;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum SignalType {
|
||||
CALL = 0,
|
||||
KEY_EXCHANGE = 1,
|
||||
ACTIVE_CALL = 2,
|
||||
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
|
||||
*/
|
||||
export class PacketSignalPeer extends Packet {
|
||||
|
||||
private src: string = "";
|
||||
/**
|
||||
* Назначение
|
||||
*/
|
||||
private dst: string = "";
|
||||
/**
|
||||
* Используется если SignalType == KEY_EXCHANGE, для идентификации сессии обмена ключами
|
||||
*/
|
||||
private sharedPublic: string = "";
|
||||
|
||||
private signalType: SignalType = SignalType.CALL;
|
||||
|
||||
private callId: string = "";
|
||||
private joinToken: string = "";
|
||||
|
||||
|
||||
public getPacketId(): number {
|
||||
return 26;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
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.dst = stream.readString();
|
||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||
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 {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
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.dst);
|
||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||
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;
|
||||
}
|
||||
|
||||
public setDst(dst: string) {
|
||||
this.dst = dst;
|
||||
}
|
||||
|
||||
public setSharedPublic(sharedPublic: string) {
|
||||
this.sharedPublic = sharedPublic;
|
||||
}
|
||||
|
||||
public setSignalType(signalType: SignalType) {
|
||||
this.signalType = signalType;
|
||||
}
|
||||
|
||||
public getDst(): string {
|
||||
return this.dst;
|
||||
}
|
||||
|
||||
public getSharedPublic(): string {
|
||||
return this.sharedPublic;
|
||||
}
|
||||
|
||||
public getSignalType(): SignalType {
|
||||
return this.signalType;
|
||||
}
|
||||
|
||||
public getSrc(): string {
|
||||
return this.src;
|
||||
}
|
||||
|
||||
public setSrc(src: string) {
|
||||
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 {
|
||||
this.status = stream.readInt8() as SyncStatus;
|
||||
this.timestamp = stream.readInt64();
|
||||
this.timestamp = Number(stream.readInt64());
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
let stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeInt8(this.status);
|
||||
stream.writeInt64(this.timestamp);
|
||||
stream.writeInt64(BigInt(this.timestamp));
|
||||
return stream;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +25,9 @@ import { PacketDeviceNew } from "./packets/packet.device.new";
|
||||
import { PacketDeviceList } from "./packets/packet.device.list";
|
||||
import { PacketDeviceResolve } from "./packets/packet.device.resolve";
|
||||
import { PacketSync } from "./packets/packet.sync";
|
||||
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 {
|
||||
private serverAddress: string;
|
||||
@@ -125,6 +128,9 @@ export default class Protocol extends EventEmitter {
|
||||
this._supportedPackets.set(0x17, new PacketDeviceList());
|
||||
this._supportedPackets.set(0x18, new PacketDeviceResolve());
|
||||
this._supportedPackets.set(25, new PacketSync());
|
||||
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)[] {
|
||||
|
||||
@@ -1,151 +1,372 @@
|
||||
export default class Stream {
|
||||
private stream: Uint8Array;
|
||||
private readPointer = 0; // bits
|
||||
private writePointer = 0; // bits
|
||||
|
||||
private _stream: number[];
|
||||
private _readPoiner: number = 0;
|
||||
private _writePointer: number = 0;
|
||||
constructor(stream?: Uint8Array | number[]) {
|
||||
if (!stream) {
|
||||
this.stream = new Uint8Array(0);
|
||||
} else {
|
||||
const src = stream instanceof Uint8Array ? stream : Uint8Array.from(stream);
|
||||
this.stream = src;
|
||||
this.writePointer = this.stream.length << 3;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(stream : number[] = []) {
|
||||
this._stream = stream;
|
||||
getStream(): Uint8Array {
|
||||
return this.stream.slice(0, this.length());
|
||||
}
|
||||
|
||||
setStream(stream?: Uint8Array | number[]) {
|
||||
if (!stream) {
|
||||
this.stream = new Uint8Array(0);
|
||||
this.readPointer = 0;
|
||||
this.writePointer = 0;
|
||||
return;
|
||||
}
|
||||
const src = stream instanceof Uint8Array ? stream : Uint8Array.from(stream);
|
||||
this.stream = src;
|
||||
this.readPointer = 0;
|
||||
this.writePointer = this.stream.length << 3;
|
||||
}
|
||||
|
||||
getBuffer(): Uint8Array {
|
||||
return this.getStream();
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.writePointer === 0;
|
||||
}
|
||||
|
||||
length(): number {
|
||||
return (this.writePointer + 7) >> 3;
|
||||
}
|
||||
|
||||
// ---------- bit / boolean ----------
|
||||
|
||||
writeBit(value: number) {
|
||||
this.writeBits(BigInt(value & 1), 1);
|
||||
}
|
||||
|
||||
readBit(): number {
|
||||
return Number(this.readBits(1));
|
||||
}
|
||||
|
||||
writeBoolean(value: boolean) {
|
||||
this.writeBit(value ? 1 : 0);
|
||||
}
|
||||
|
||||
readBoolean(): boolean {
|
||||
return this.readBit() === 1;
|
||||
}
|
||||
|
||||
// ---------- byte ----------
|
||||
|
||||
writeByte(b: number) {
|
||||
this.writeUInt8(b & 0xff);
|
||||
}
|
||||
|
||||
readByte(): number {
|
||||
const v = this.readUInt8();
|
||||
return (v << 24) >> 24; // signed byte
|
||||
}
|
||||
|
||||
// ---------- UInt / Int 8 ----------
|
||||
|
||||
writeUInt8(value: number) {
|
||||
const v = value & 0xff;
|
||||
|
||||
if ((this.writePointer & 7) === 0) {
|
||||
this.reserveBits(8);
|
||||
this.stream[this.writePointer >> 3] = v;
|
||||
this.writePointer += 8;
|
||||
return;
|
||||
}
|
||||
|
||||
public getStream(): number[] {
|
||||
return this._stream;
|
||||
this.writeBits(BigInt(v), 8);
|
||||
}
|
||||
|
||||
readUInt8(): number {
|
||||
if (this.remainingBits() < 8n) {
|
||||
throw new Error("Not enough bits to read UInt8");
|
||||
}
|
||||
|
||||
public setStream(stream: number[]) {
|
||||
this._stream = stream;
|
||||
if ((this.readPointer & 7) === 0) {
|
||||
const v = this.stream[this.readPointer >> 3] & 0xff;
|
||||
this.readPointer += 8;
|
||||
return v;
|
||||
}
|
||||
|
||||
public writeInt8(value: number) {
|
||||
const negationBit = value < 0 ? 1 : 0;
|
||||
const int8Value = Math.abs(value) & 0xFF;
|
||||
this._stream[this._writePointer >> 3] |= negationBit << (7 - (this._writePointer & 7));
|
||||
this._writePointer++;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const bit = (int8Value >> (7 - i)) & 1;
|
||||
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
|
||||
this._writePointer++;
|
||||
}
|
||||
return Number(this.readBits(8));
|
||||
}
|
||||
|
||||
writeInt8(value: number) {
|
||||
this.writeUInt8(value);
|
||||
}
|
||||
|
||||
readInt8(): number {
|
||||
const u = this.readUInt8();
|
||||
return (u << 24) >> 24;
|
||||
}
|
||||
|
||||
// ---------- UInt / Int 16 ----------
|
||||
|
||||
writeUInt16(value: number) {
|
||||
const v = value & 0xffff;
|
||||
this.writeUInt8((v >>> 8) & 0xff);
|
||||
this.writeUInt8(v & 0xff);
|
||||
}
|
||||
|
||||
readUInt16(): number {
|
||||
const hi = this.readUInt8();
|
||||
const lo = this.readUInt8();
|
||||
return (hi << 8) | lo;
|
||||
}
|
||||
|
||||
writeInt16(value: number) {
|
||||
this.writeUInt16(value);
|
||||
}
|
||||
|
||||
readInt16(): number {
|
||||
const u = this.readUInt16();
|
||||
return (u << 16) >> 16;
|
||||
}
|
||||
|
||||
// ---------- UInt / Int 32 ----------
|
||||
|
||||
writeUInt32(value: number) {
|
||||
if (!Number.isFinite(value) || value < 0 || value > 0xffffffff) {
|
||||
throw new Error(`UInt32 out of range: ${value}`);
|
||||
}
|
||||
|
||||
public readInt8(): number {
|
||||
let value = 0;
|
||||
const negationBit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
||||
this._readPoiner++;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
||||
value |= bit << (7 - i);
|
||||
this._readPoiner++;
|
||||
}
|
||||
return negationBit ? -value : value;
|
||||
const v = Math.floor(value);
|
||||
this.writeUInt8((v >>> 24) & 0xff);
|
||||
this.writeUInt8((v >>> 16) & 0xff);
|
||||
this.writeUInt8((v >>> 8) & 0xff);
|
||||
this.writeUInt8(v & 0xff);
|
||||
}
|
||||
|
||||
readUInt32(): number {
|
||||
const b1 = this.readUInt8();
|
||||
const b2 = this.readUInt8();
|
||||
const b3 = this.readUInt8();
|
||||
const b4 = this.readUInt8();
|
||||
return (((b1 * 0x1000000) + (b2 << 16) + (b3 << 8) + b4) >>> 0);
|
||||
}
|
||||
|
||||
writeInt32(value: number) {
|
||||
this.writeUInt32(value >>> 0);
|
||||
}
|
||||
|
||||
readInt32(): number {
|
||||
return this.readUInt32() | 0;
|
||||
}
|
||||
|
||||
// ---------- UInt / Int 64 ----------
|
||||
|
||||
writeUInt64(value: bigint) {
|
||||
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) {
|
||||
throw new Error(`UInt64 out of range: ${value.toString()}`);
|
||||
}
|
||||
|
||||
public writeBit(value: number) {
|
||||
const bit = value & 1;
|
||||
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
|
||||
this._writePointer++;
|
||||
this.writeUInt8(Number((value >> 56n) & 0xffn));
|
||||
this.writeUInt8(Number((value >> 48n) & 0xffn));
|
||||
this.writeUInt8(Number((value >> 40n) & 0xffn));
|
||||
this.writeUInt8(Number((value >> 32n) & 0xffn));
|
||||
this.writeUInt8(Number((value >> 24n) & 0xffn));
|
||||
this.writeUInt8(Number((value >> 16n) & 0xffn));
|
||||
this.writeUInt8(Number((value >> 8n) & 0xffn));
|
||||
this.writeUInt8(Number(value & 0xffn));
|
||||
}
|
||||
|
||||
readUInt64(): bigint {
|
||||
const high = BigInt(this.readUInt32() >>> 0);
|
||||
const low = BigInt(this.readUInt32() >>> 0);
|
||||
return (high << 32n) | low;
|
||||
}
|
||||
|
||||
writeInt64(value: bigint) {
|
||||
const u = BigInt.asUintN(64, value);
|
||||
this.writeUInt64(u);
|
||||
}
|
||||
|
||||
readInt64(): bigint {
|
||||
return BigInt.asIntN(64, this.readUInt64());
|
||||
}
|
||||
|
||||
// ---------- float ----------
|
||||
|
||||
writeFloat32(value: number) {
|
||||
const ab = new ArrayBuffer(4);
|
||||
const dv = new DataView(ab);
|
||||
dv.setFloat32(0, value, false); // big-endian
|
||||
this.writeUInt8(dv.getUint8(0));
|
||||
this.writeUInt8(dv.getUint8(1));
|
||||
this.writeUInt8(dv.getUint8(2));
|
||||
this.writeUInt8(dv.getUint8(3));
|
||||
}
|
||||
|
||||
readFloat32(): number {
|
||||
const ab = new ArrayBuffer(4);
|
||||
const dv = new DataView(ab);
|
||||
dv.setUint8(0, this.readUInt8());
|
||||
dv.setUint8(1, this.readUInt8());
|
||||
dv.setUint8(2, this.readUInt8());
|
||||
dv.setUint8(3, this.readUInt8());
|
||||
return dv.getFloat32(0, false); // big-endian
|
||||
}
|
||||
|
||||
// ---------- string / bytes ----------
|
||||
// String: length(UInt32) + chars(UInt16), как в Java charAt()
|
||||
|
||||
writeString(value: string | null | undefined) {
|
||||
const s = value ?? "";
|
||||
this.writeUInt32(s.length);
|
||||
|
||||
if (s.length === 0) return;
|
||||
|
||||
this.reserveBits(BigInt(s.length) * 16n);
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
this.writeUInt16(s.charCodeAt(i) & 0xffff);
|
||||
}
|
||||
}
|
||||
|
||||
readString(): string {
|
||||
const len = this.readUInt32();
|
||||
if (len > 0x7fffffff) {
|
||||
throw new Error(`String length too large: ${len}`);
|
||||
}
|
||||
|
||||
public readBit(): number {
|
||||
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
||||
this._readPoiner++;
|
||||
return bit;
|
||||
const requiredBits = BigInt(len) * 16n;
|
||||
if (requiredBits > this.remainingBits()) {
|
||||
throw new Error("Not enough bits to read string");
|
||||
}
|
||||
|
||||
public writeBoolean(value: boolean) {
|
||||
this.writeBit(value ? 1 : 0);
|
||||
const chars = new Array<number>(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
chars[i] = this.readUInt16();
|
||||
}
|
||||
return String.fromCharCode(...chars);
|
||||
}
|
||||
|
||||
// byte[]: length(UInt32) + payload
|
||||
writeBytes(value: Uint8Array | number[] | null | undefined) {
|
||||
const arr = value == null
|
||||
? new Uint8Array(0)
|
||||
: (value instanceof Uint8Array ? value : Uint8Array.from(value));
|
||||
|
||||
this.writeUInt32(arr.length);
|
||||
if (arr.length === 0) return;
|
||||
|
||||
this.reserveBits(BigInt(arr.length) * 8n);
|
||||
|
||||
if ((this.writePointer & 7) === 0) {
|
||||
const byteIndex = this.writePointer >> 3;
|
||||
this.ensureCapacity(byteIndex + arr.length - 1);
|
||||
this.stream.set(arr, byteIndex);
|
||||
this.writePointer += arr.length << 3;
|
||||
return;
|
||||
}
|
||||
|
||||
public readBoolean(): boolean {
|
||||
return this.readBit() === 1;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
this.writeUInt8(arr[i]);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
this.writeInt8(value >> 8);
|
||||
this.writeInt8(value & 0xFF);
|
||||
const out = new Uint8Array(len);
|
||||
|
||||
if ((this.readPointer & 7) === 0) {
|
||||
const byteIndex = this.readPointer >> 3;
|
||||
out.set(this.stream.slice(byteIndex, byteIndex + len));
|
||||
this.readPointer += len << 3;
|
||||
return out;
|
||||
}
|
||||
|
||||
public readInt16(): number {
|
||||
const value = this.readInt8() << 8;
|
||||
return value | this.readInt8();
|
||||
for (let i = 0; i < len; i++) {
|
||||
out[i] = this.readUInt8();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- internals ----------
|
||||
|
||||
private remainingBits(): bigint {
|
||||
return BigInt(this.writePointer - this.readPointer);
|
||||
}
|
||||
|
||||
private writeBits(value: bigint, bits: number) {
|
||||
if (bits <= 0) return;
|
||||
|
||||
this.reserveBits(bits);
|
||||
|
||||
for (let i = bits - 1; i >= 0; i--) {
|
||||
const bit = Number((value >> BigInt(i)) & 1n);
|
||||
const byteIndex = this.writePointer >> 3;
|
||||
const shift = 7 - (this.writePointer & 7);
|
||||
|
||||
if (bit === 1) {
|
||||
this.stream[byteIndex] = this.stream[byteIndex] | (1 << shift);
|
||||
} else {
|
||||
this.stream[byteIndex] = this.stream[byteIndex] & ~(1 << shift);
|
||||
}
|
||||
this.writePointer++;
|
||||
}
|
||||
}
|
||||
|
||||
private readBits(bits: number): bigint {
|
||||
if (bits <= 0) return 0n;
|
||||
if (this.remainingBits() < BigInt(bits)) {
|
||||
throw new Error("Not enough bits to read");
|
||||
}
|
||||
|
||||
public writeInt32(value: number) {
|
||||
this.writeInt16(value >> 16);
|
||||
this.writeInt16(value & 0xFFFF);
|
||||
let value = 0n;
|
||||
for (let i = 0; i < bits; i++) {
|
||||
const bit = (this.stream[this.readPointer >> 3] >> (7 - (this.readPointer & 7))) & 1;
|
||||
value = (value << 1n) | BigInt(bit);
|
||||
this.readPointer++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private reserveBits(bitsToWrite: number | bigint) {
|
||||
const bits = typeof bitsToWrite === "number" ? BigInt(bitsToWrite) : bitsToWrite;
|
||||
if (bits <= 0n) return;
|
||||
|
||||
const lastBitIndex = BigInt(this.writePointer) + bits - 1n;
|
||||
if (lastBitIndex < 0n) throw new Error("Bit index overflow");
|
||||
|
||||
const byteIndex = lastBitIndex >> 3n;
|
||||
if (byteIndex > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new Error("Stream too large");
|
||||
}
|
||||
|
||||
public readInt32(): number {
|
||||
const value = this.readInt16() << 16;
|
||||
return value | this.readInt16();
|
||||
}
|
||||
|
||||
public writeInt64(value: number) {
|
||||
const high = Math.floor(value / 0x100000000);
|
||||
const low = value >>> 0;
|
||||
this.writeInt32(high);
|
||||
this.writeInt32(low);
|
||||
}
|
||||
|
||||
public readInt64(): number {
|
||||
const high = this.readInt32();
|
||||
const low = this.readInt32() >>> 0;
|
||||
return high * 0x100000000 + low;
|
||||
}
|
||||
|
||||
public writeFloat32(value: number) {
|
||||
const buffer = new ArrayBuffer(4);
|
||||
new DataView(buffer).setFloat32(0, value, true);
|
||||
const float32Value = new Uint32Array(buffer)[0];
|
||||
this.writeInt32(float32Value);
|
||||
}
|
||||
|
||||
public readFloat32(): number {
|
||||
const float32Value = this.readInt32();
|
||||
const buffer = new ArrayBuffer(4);
|
||||
new Uint32Array(buffer)[0] = float32Value;
|
||||
return new DataView(buffer).getFloat32(0, true);
|
||||
}
|
||||
|
||||
public writeString(value: string) {
|
||||
let length = value.length;
|
||||
this.writeInt32(length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
this.writeInt16(value.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
public readString(): string {
|
||||
let length = this.readInt32();
|
||||
/**
|
||||
* Фикс уязвимости с длинной строки, превышающей
|
||||
* возможность для чтения _stream
|
||||
*/
|
||||
if (length < 0 || length > (this._stream.length - (this._readPoiner >> 3))) {
|
||||
console.info("Stream readString length invalid", length, this._stream.length, this._readPoiner);
|
||||
return "";
|
||||
}
|
||||
let value = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
value += String.fromCharCode(this.readInt16());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public writeBytes(value: number[]) {
|
||||
this.writeInt32(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
this.writeInt8(value[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public readBytes(): number[] {
|
||||
let length = this.readInt32();
|
||||
let value : any = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
value.push(this.readInt8());
|
||||
}
|
||||
return value;
|
||||
this.ensureCapacity(Number(byteIndex));
|
||||
}
|
||||
|
||||
private ensureCapacity(byteIndex: number) {
|
||||
const requiredSize = byteIndex + 1;
|
||||
if (requiredSize <= this.stream.length) return;
|
||||
|
||||
let newSize = this.stream.length === 0 ? 32 : this.stream.length;
|
||||
while (newSize < requiredSize) {
|
||||
if (newSize > (0x7fffffff >> 1)) {
|
||||
newSize = requiredSize;
|
||||
break;
|
||||
}
|
||||
newSize <<= 1;
|
||||
}
|
||||
|
||||
const next = new Uint8Array(newSize);
|
||||
next.set(this.stream);
|
||||
this.stream = next;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createContext } from "react";
|
||||
import { useSystemAccount } from "./useSystemAccount";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { chacha20Encrypt, encodeWithPassword, encrypt } from "@/app/crypto/crypto";
|
||||
import { chacha20Encrypt, encodeWithPassword, encrypt } from "@/app/workers/crypto/crypto";
|
||||
import { generateRandomKey } from "@/app/utils/utils";
|
||||
import { DeliveredMessageState } from "../DialogProvider/DialogProvider";
|
||||
import { UserInformation } from "../InformationProvider/InformationProvider";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { decodeWithPassword, encodeWithPassword } from "@/app/crypto/crypto";
|
||||
import { decodeWithPassword, encodeWithPassword } from "@/app/workers/crypto/crypto";
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { generateRandomKey } from "@/app/utils/utils";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
import { useCore } from "@/app/hooks/useCore";
|
||||
|
||||
interface SystemProviderContextValue {
|
||||
id: string;
|
||||
@@ -21,7 +22,10 @@ export interface SystemProviderProps {
|
||||
*/
|
||||
export function SystemProvider(props: SystemProviderProps) {
|
||||
const [deviceId, setDeviceId] = useState<string>("");
|
||||
const [deviceName, setDeviceName] = useState<string>("");
|
||||
const [deviceOs, setDeviceOs] = useState<string>("");
|
||||
const {writeFile, readFile} = useFileStorage();
|
||||
const { getDeviceId, getDeviceName, getPlatform } = useCore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeviceId();
|
||||
@@ -29,6 +33,10 @@ export function SystemProvider(props: SystemProviderProps) {
|
||||
|
||||
const fetchDeviceId = async () => {
|
||||
const device = await readFile("device");
|
||||
const name = await getDeviceName();
|
||||
const platform = await getPlatform();
|
||||
setDeviceName(name);
|
||||
setDeviceOs(platform);
|
||||
if(device){
|
||||
const decoded = await decodeDevice(Buffer.from(device).toString('utf-8'));
|
||||
if(decoded){
|
||||
@@ -47,12 +55,11 @@ export function SystemProvider(props: SystemProviderProps) {
|
||||
}
|
||||
|
||||
const decodeDevice = async (data: string) => {
|
||||
const hwid = window.deviceId;
|
||||
const platform = window.deviceName;
|
||||
const hwid = await getDeviceId();
|
||||
const deviceName = await getDeviceName();
|
||||
const salt = "rosetta-device-salt";
|
||||
|
||||
try {
|
||||
const decoded = await decodeWithPassword(hwid + platform + salt, data);
|
||||
const decoded = await decodeWithPassword(hwid + deviceName + salt, data);
|
||||
return decoded;
|
||||
} catch (e) {
|
||||
console.error("Failed to decode device data:", e);
|
||||
@@ -61,12 +68,12 @@ export function SystemProvider(props: SystemProviderProps) {
|
||||
}
|
||||
|
||||
const encodeDevice = async (data: string) => {
|
||||
const hwid = window.deviceId;
|
||||
const platform = window.deviceName;
|
||||
const hwid = await getDeviceId();
|
||||
const deviceName = await getDeviceName();
|
||||
const salt = "rosetta-device-salt";
|
||||
|
||||
try {
|
||||
const encoded = await encodeWithPassword(hwid + platform + salt, data);
|
||||
const encoded = await encodeWithPassword(hwid + deviceName + salt, data);
|
||||
return encoded;
|
||||
} catch (e) {
|
||||
console.error("Failed to encode device data:", e);
|
||||
@@ -74,16 +81,11 @@ export function SystemProvider(props: SystemProviderProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const systemName = window.deviceName || "Unknown Device";
|
||||
const systemOs = window.platform || "Unknown OS";
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<SystemProviderContext.Provider value={{
|
||||
id: deviceId,
|
||||
name: systemName,
|
||||
os: systemOs
|
||||
name: deviceName || "Unknown Device",
|
||||
os: deviceOs || "Unknown OS"
|
||||
}}>
|
||||
{props.children}
|
||||
</SystemProviderContext.Provider>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
interface TransportContextValue {
|
||||
transportServer: string | null;
|
||||
uploadFile: (id: string, content: string) => Promise<any>;
|
||||
downloadFile: (id: string, tag: string) => Promise<string>;
|
||||
downloadFile: (id: string, tag: string, transportServer: string) => Promise<string>;
|
||||
uploading: TransportState[];
|
||||
downloading: TransportState[];
|
||||
}
|
||||
@@ -86,14 +86,14 @@ export function TransportProvider(props: TransportProviderProps) {
|
||||
* @param tag тег файла
|
||||
* @param chachaDecryptedKey ключ для расшифровки файла
|
||||
*/
|
||||
const downloadFile = (id: string, tag : string) : Promise<string> => {
|
||||
const downloadFile = (id: string, tag : string, transportServer: string) : Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!transportServerRef.current) {
|
||||
if (!transportServer) {
|
||||
throw new Error("Transport server is not set");
|
||||
}
|
||||
setDownloading(prev => [...prev, { id: id, progress: 0 }]);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', `${transportServerRef.current}/d/${tag}`);
|
||||
xhr.open('GET', `${transportServer}/d/${tag}`);
|
||||
xhr.responseType = 'text';
|
||||
|
||||
xhr.onprogress = (event) => {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { APPLICATION_ARCH, APPLICATION_PLATFROM, CORE_VERSION } from "@/app/constants";
|
||||
import { APP_VERSION } from "@/app/version";
|
||||
import { useCore } from "@/app/hooks/useCore";
|
||||
|
||||
export interface UpdateProviderProps {
|
||||
children: React.ReactNode;
|
||||
@@ -39,6 +39,7 @@ export interface UpdateContextValue {
|
||||
checkForUpdates: () => void;
|
||||
downloadLastApplicationUpdate: () => void;
|
||||
restartAppForUpdateApply: () => void;
|
||||
updateServer: string | null;
|
||||
}
|
||||
|
||||
export const UpdateProviderContext = createContext<UpdateContextValue | null>(null);
|
||||
@@ -57,6 +58,7 @@ export function UpdateProvider(props: UpdateProviderProps) {
|
||||
const [appUpdateUrl, setAppUpdateUrl] = useState<string>("");
|
||||
const [appActualVersion, setAppActualVersion] = useState<string>("");
|
||||
const {writeFile} = useFileStorage();
|
||||
const {getCoreVersion, getArch, getPlatform} = useCore();
|
||||
|
||||
useEffect(() => {
|
||||
let packet = new PacketRequestUpdate();
|
||||
@@ -74,6 +76,9 @@ export function UpdateProvider(props: UpdateProviderProps) {
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
const coreVersion = await getCoreVersion();
|
||||
const arch = await getArch();
|
||||
const platform = await getPlatform();
|
||||
if(updateServerRef.current == null){
|
||||
/**
|
||||
* SDU еще не определен
|
||||
@@ -84,7 +89,7 @@ export function UpdateProvider(props: UpdateProviderProps) {
|
||||
* Запрашиваем обновления с SDU сервера
|
||||
*/
|
||||
let response = await fetch
|
||||
(`${updateServerRef.current}/updates/get?app=${APP_VERSION}&kernel=${CORE_VERSION}&arch=${APPLICATION_ARCH}&platform=${APPLICATION_PLATFROM}`).catch((e) => {
|
||||
(`${updateServerRef.current}/updates/get?app=${APP_VERSION}&kernel=${coreVersion}&arch=${arch}&platform=${platform}`).catch((e) => {
|
||||
error("Failed to check for updates: " + e.message);
|
||||
});
|
||||
if(!response || response.status != 200){
|
||||
@@ -167,7 +172,8 @@ export function UpdateProvider(props: UpdateProviderProps) {
|
||||
kernelUpdateUrl,
|
||||
checkForUpdates,
|
||||
downloadLastApplicationUpdate,
|
||||
restartAppForUpdateApply
|
||||
restartAppForUpdateApply,
|
||||
updateServer: updateServerRef.current
|
||||
}}>
|
||||
{props.children}
|
||||
</UpdateProviderContext.Provider>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const SERVERS = [
|
||||
//'wss://cdn.rosetta-im.com',
|
||||
//'ws://10.211.55.2:3000',
|
||||
//'ws://127.0.0.1:3000',
|
||||
//'ws://192.168.6.82:3000',
|
||||
'wss://wss.rosetta.im'
|
||||
];
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
|
||||
return "$a=File";
|
||||
case AttachmentType.AVATAR:
|
||||
return "$a=Avatar";
|
||||
case AttachmentType.CALL:
|
||||
return "$a=Call";
|
||||
case AttachmentType.VOICE:
|
||||
return "$a=Voice message";
|
||||
default:
|
||||
return "[Unsupported attachment]";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MantineColor } from "@mantine/core";
|
||||
import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "../constants";
|
||||
import { decode, encode } from "blurhash";
|
||||
|
||||
export function generateRandomKey(length: number): string {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
@@ -243,47 +242,3 @@ export function isImage(blob : string) : boolean {
|
||||
}
|
||||
return blob.startsWith('data:image/');
|
||||
}
|
||||
|
||||
export function blurhashToBase64Image(blurhash: string, width: number, height: number): string {
|
||||
const pixels = decode(blurhash, width, height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = ctx?.createImageData(width, height);
|
||||
if (imageData) {
|
||||
imageData.data.set(pixels);
|
||||
ctx?.putImageData(imageData, 0, 0);
|
||||
return canvas.toDataURL();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function base64ImageToBlurhash(base64Image: string): Promise<string> {
|
||||
const img = new Image();
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
img.onload = () => {
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx?.drawImage(img, 0, 0);
|
||||
const imageData = ctx?.getImageData(0, 0, canvas.width, canvas.height);
|
||||
if (imageData) {
|
||||
const blurhash = encode(
|
||||
imageData.data,
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
4,
|
||||
4
|
||||
);
|
||||
resolve(blurhash);
|
||||
} else {
|
||||
reject('Failed to get image data from canvas.');
|
||||
}
|
||||
};
|
||||
img.onerror = (error) => reject(error);
|
||||
img.src = base64Image;
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
export const APP_VERSION = "1.0.2";
|
||||
export const CORE_MIN_REQUIRED_VERSION = "1.4.9";
|
||||
export const APP_VERSION = "1.2.2";
|
||||
export const CORE_MIN_REQUIRED_VERSION = "1.5.5";
|
||||
|
||||
export const RELEASE_NOTICE = `
|
||||
**Update v1.0.2** :emoji_1f631:
|
||||
- Support multiple file downloads
|
||||
- Fix fallback after boot loading
|
||||
- Fix corss-chat reading messages
|
||||
- Support sync attachments on other devices
|
||||
- Fix UI bugs
|
||||
**Обновление v1.2.2** :emoji_1f631:
|
||||
- Поддержка записи и прослушивания голосовых сообщений
|
||||
`;
|
||||
@@ -3,7 +3,7 @@ import { InternalScreen } from "@/app/components/InternalScreen/InternalScreen";
|
||||
import { SettingsAlert } from "@/app/components/SettingsAlert/SettingsAlert";
|
||||
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
|
||||
import { TextChain } from "@/app/components/TextChain/TextChain";
|
||||
import { decodeWithPassword } from "@/app/crypto/crypto";
|
||||
import { decodeWithPassword } from "@/app/workers/crypto/crypto";
|
||||
import { useAccount } from "@/app/providers/AccountProvider/useAccount";
|
||||
import { Text } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -9,12 +9,13 @@ import { useEffect } from "react";
|
||||
import { useViewPanelsState, ViewPanelsState } from "@/app/hooks/useViewPanelsState";
|
||||
import { GroupHeader } from "@/app/components/GroupHeader/GroupHeader";
|
||||
import { useGroups } from "@/app/providers/DialogProvider/useGroups";
|
||||
import { ActiveCall } from "@/app/components/ActiveCall/ActiveCall";
|
||||
|
||||
export function Chat() {
|
||||
const params = useParams();
|
||||
const dialog = params.id || "DELETED";
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const [__, setViewState] = useViewPanelsState();
|
||||
const [viewState, setViewState] = useViewPanelsState();
|
||||
const {hasGroup} = useGroups();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,6 +31,9 @@ export function Chat() {
|
||||
return (<>
|
||||
<DialogProvider dialog={dialog} key={dialog}>
|
||||
<Flex direction={'column'} justify={'space-between'} h={'100%'}>
|
||||
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && (
|
||||
<ActiveCall></ActiveCall>
|
||||
)}
|
||||
{/* Group Header */}
|
||||
{hasGroup(dialog) && <GroupHeader></GroupHeader>}
|
||||
{/* Dialog peer to peer Header */}
|
||||
|
||||
@@ -10,11 +10,14 @@ import { AnimatedButton } from "@/app/components/AnimatedButton/AnimatedButton";
|
||||
import { useLogout } from "@/app/providers/AccountProvider/useLogout";
|
||||
import { usePacket } from "@/app/providers/ProtocolProvider/usePacket";
|
||||
import { PacketDeviceResolve, Solution } from "@/app/providers/ProtocolProvider/protocol/packets/packet.device.resolve";
|
||||
import { useCoreDevice } from "@/app/providers/DeviceProvider/useCoreDevice";
|
||||
|
||||
export function DeviceConfirm() {
|
||||
const [protocolState] = useProtocolState();
|
||||
const navigate = useNavigate();
|
||||
const logout = useLogout();
|
||||
const {deviceName} = useCoreDevice();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if(protocolState == ProtocolState.CONNECTED) {
|
||||
@@ -60,7 +63,7 @@ export function DeviceConfirm() {
|
||||
<Flex justify={'center'} mt={'xl'} px={'lg'} align={'center'}>
|
||||
<Flex justify={'center'} gap={'sm'} align={'center'}>
|
||||
<Text ta={'center'} c={'dimmed'} fz={12}>
|
||||
Confirm device <strong>{window.deviceName}</strong> on your first device to loading your chats.
|
||||
Confirm device <strong>{deviceName}</strong> on your first device to loading your chats.
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -3,7 +3,7 @@ import classes from './Lockscreen.module.css'
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useWindow from "@/app/hooks/useWindow";
|
||||
import { decodeWithPassword, generateHashFromPrivateKey } from "@/app/crypto/crypto";
|
||||
import { decodeWithPassword, generateHashFromPrivateKey } from "@/app/workers/crypto/crypto";
|
||||
import { useAccountProvider } from "@/app/providers/AccountProvider/useAccountProvider";
|
||||
import { Account, AccountBase } from "@/app/providers/AccountProvider/AccountProvider";
|
||||
import { useUserCache } from "@/app/providers/InformationProvider/useUserCache";
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useUpdateMessage } from "@/app/hooks/useUpdateMessage";
|
||||
import { useDeviceMessage } from "@/app/hooks/useDeviceMessage";
|
||||
import { UpdateProvider } from "@/app/providers/UpdateProvider/UpdateProvider";
|
||||
import { useSynchronize } from "@/app/providers/DialogProvider/useSynchronize";
|
||||
import { CallProvider } from "@/app/providers/CallProvider/CallProvider";
|
||||
|
||||
export function Main() {
|
||||
const { mainColor, borderColor } = useRosettaColors();
|
||||
@@ -154,52 +155,56 @@ export function Main() {
|
||||
<SystemAccountProvider>
|
||||
<TransportProvider>
|
||||
<UpdateProvider>
|
||||
<Flex direction={'row'} style={{
|
||||
height: '100%',
|
||||
width: '100vw',
|
||||
}}>
|
||||
<div style={{
|
||||
display: viewState != ViewPanelsState.DIALOGS_PANEL_HIDE ? 'block' : 'none',
|
||||
width: viewState == ViewPanelsState.DIALOGS_PANEL_ONLY ? '100%' : '300px',
|
||||
<CallProvider>
|
||||
<Flex direction={'row'} style={{
|
||||
height: '100%',
|
||||
width: '100vw',
|
||||
}}>
|
||||
<DialogsPanel></DialogsPanel>
|
||||
</div>
|
||||
<Divider color={borderColor} orientation={'vertical'}></Divider>
|
||||
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && <Box
|
||||
bg={mainColor}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
height: 'calc(100vh - 27px)',
|
||||
width: `calc(100% - 300px)`,
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path={'/chat/:id'} element={<Chat />}></Route>
|
||||
<Route path={'/profile/:id'} element={<Profile />}></Route>
|
||||
<Route path={'/'} element={<DialogPreview />}></Route>
|
||||
<Route path={'/theme'} element={<Theme />}></Route>
|
||||
<Route path={'/safety'} element={<Safety />}></Route>
|
||||
<Route path={'/update'} element={<Update />}></Route>
|
||||
<Route path={'/backup'} element={<Backup />}></Route>
|
||||
<Route path={'/dialogs'} element={<Dialogs />}></Route>
|
||||
<Route path={'/newgroup'} element={<CreateGroup />}></Route>
|
||||
<Route path={'/group/:id'} element={<GroupInfo />}></Route>
|
||||
<Route path={'/groupencrypt/:key'} element={<GroupEncryption />}></Route>
|
||||
</Routes>
|
||||
</Box>}
|
||||
</Flex>
|
||||
{oldPublicKey && (
|
||||
<Overlay blur={8} color="#333">
|
||||
<Flex direction={'column'} align={'center'} justify={'center'} h={'100%'}>
|
||||
<Alert w={400} variant="filled" color="red" title="Old account">
|
||||
Your account uses an old format public key which is no longer supported. Please create a new account to continue using the application.
|
||||
<br></br>After press "OK" button, the application will close and remove all data.
|
||||
</Alert>
|
||||
<Button w={400} mt={'md'} color="red" onClick={dropAccountsAndMessages}>OK</Button>
|
||||
</Flex>
|
||||
</Overlay>
|
||||
)}
|
||||
<div style={{
|
||||
display: viewState != ViewPanelsState.DIALOGS_PANEL_HIDE ? 'block' : 'none',
|
||||
width: viewState == ViewPanelsState.DIALOGS_PANEL_ONLY ? '100%' : '300px',
|
||||
}}>
|
||||
<DialogsPanel></DialogsPanel>
|
||||
</div>
|
||||
{lg && (
|
||||
<Divider color={borderColor} orientation={'vertical'}></Divider>
|
||||
)}
|
||||
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && <Box
|
||||
bg={mainColor}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
height: 'calc(100vh - 27px)',
|
||||
width: `calc(100% - 300px)`,
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path={'/chat/:id'} element={<Chat />}></Route>
|
||||
<Route path={'/profile/:id'} element={<Profile />}></Route>
|
||||
<Route path={'/'} element={<DialogPreview />}></Route>
|
||||
<Route path={'/theme'} element={<Theme />}></Route>
|
||||
<Route path={'/safety'} element={<Safety />}></Route>
|
||||
<Route path={'/update'} element={<Update />}></Route>
|
||||
<Route path={'/backup'} element={<Backup />}></Route>
|
||||
<Route path={'/dialogs'} element={<Dialogs />}></Route>
|
||||
<Route path={'/newgroup'} element={<CreateGroup />}></Route>
|
||||
<Route path={'/group/:id'} element={<GroupInfo />}></Route>
|
||||
<Route path={'/groupencrypt/:key'} element={<GroupEncryption />}></Route>
|
||||
</Routes>
|
||||
</Box>}
|
||||
</Flex>
|
||||
{oldPublicKey && (
|
||||
<Overlay blur={8} color="#333">
|
||||
<Flex direction={'column'} align={'center'} justify={'center'} h={'100%'}>
|
||||
<Alert w={400} variant="filled" color="red" title="Old account">
|
||||
Your account uses an old format public key which is no longer supported. Please create a new account to continue using the application.
|
||||
<br></br>After press "OK" button, the application will close and remove all data.
|
||||
</Alert>
|
||||
<Button w={400} mt={'md'} color="red" onClick={dropAccountsAndMessages}>OK</Button>
|
||||
</Flex>
|
||||
</Overlay>
|
||||
)}
|
||||
</CallProvider>
|
||||
</UpdateProvider>
|
||||
</TransportProvider>
|
||||
</SystemAccountProvider>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { mnemonicToSeed } from "web-bip39";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { Buffer } from 'buffer'
|
||||
import { encodeWithPassword, generateHashFromPrivateKey, generateKeyPairFromSeed } from "@/app/crypto/crypto";
|
||||
import { encodeWithPassword, generateHashFromPrivateKey, generateKeyPairFromSeed } from "@/app/workers/crypto/crypto";
|
||||
import { useAccountProvider } from "@/app/providers/AccountProvider/useAccountProvider";
|
||||
import { Account } from "@/app/providers/AccountProvider/AccountProvider";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
|
||||
@@ -4,14 +4,25 @@ import { RosettaPower } from "@/app/components/RosettaPower/RosettaPower";
|
||||
import { SettingsAlert } from "@/app/components/SettingsAlert/SettingsAlert";
|
||||
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
|
||||
import { UpdateAlert } from "@/app/components/UpdateAlert/UpdateAlert";
|
||||
import { CORE_VERSION } from "@/app/constants";
|
||||
import { useCore } from "@/app/hooks/useCore";
|
||||
import { UpdateStatus } from "@/app/providers/UpdateProvider/UpdateProvider";
|
||||
import { useUpdater } from "@/app/providers/UpdateProvider/useUpdater";
|
||||
import { APP_VERSION } from "@/app/version";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function Update() {
|
||||
const {updateStatus} = useUpdater();
|
||||
const {getCoreVersion} = useCore();
|
||||
const [coreVersion, setCoreVersion] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCoreVersion = async () => {
|
||||
const version = await getCoreVersion();
|
||||
setCoreVersion(version);
|
||||
}
|
||||
fetchCoreVersion();
|
||||
}, [getCoreVersion]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -23,7 +34,7 @@ export function Update() {
|
||||
<Box mt={'sm'}>
|
||||
<UpdateAlert radius={'sm'}></UpdateAlert>
|
||||
</Box>
|
||||
<SettingsInput.Copy mt={'sm'} hit="Kernel" value={CORE_VERSION}></SettingsInput.Copy>
|
||||
<SettingsInput.Copy mt={'sm'} hit="Kernel" value={coreVersion}></SettingsInput.Copy>
|
||||
<Text fz={10} mt={3} c={'gray'} pl={'xs'} pr={'xs'}>
|
||||
If the kernel version is outdated, you need to reinstall the application so that this kernel continues to receive current updates.
|
||||
</Text>
|
||||
|
||||
88
app/workers/crypto/crypto.ts
Normal file
88
app/workers/crypto/crypto.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { sha256, md5 } from "node-forge";
|
||||
import * as secp256k1 from '@noble/secp256k1';
|
||||
|
||||
const worker = new Worker(new URL('./crypto.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
type WorkerReq =
|
||||
| { id: number; type: 'encodeWithPassword'; payload: { password: string; data: any } }
|
||||
| { id: number; type: 'decodeWithPassword'; payload: { password: string; data: any } }
|
||||
| { id: number; type: 'encrypt'; payload: { publicKey: string; data: string } }
|
||||
| { id: number; type: 'decrypt'; payload: { privateKey: string; data: string } }
|
||||
| { id: number; type: 'chacha20Encrypt'; payload: { data: string } }
|
||||
| { id: number; type: 'chacha20Decrypt'; payload: { ciphertext: string; nonce: string; key: string } };
|
||||
|
||||
type WorkerRes =
|
||||
| { id: number; ok: true; data: any }
|
||||
| { id: number; ok: false; error: string };
|
||||
|
||||
let seq = 0;
|
||||
const pending = new Map<number, (res: WorkerRes) => void>();
|
||||
|
||||
worker.onmessage = (e: MessageEvent<WorkerRes>) => {
|
||||
const res = e.data;
|
||||
const cb = pending.get(res.id);
|
||||
if (cb) {
|
||||
pending.delete(res.id);
|
||||
cb(res);
|
||||
}
|
||||
};
|
||||
|
||||
function callWorker(req: Omit<WorkerReq, 'id'>): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
pending.set(id, (res) => (res.ok ? resolve(res.data) : reject(res.error)));
|
||||
worker.postMessage({ ...req, id });
|
||||
});
|
||||
}
|
||||
|
||||
export const encodeWithPassword = (password: string, data: any): Promise<any> => {
|
||||
return callWorker({ type: 'encodeWithPassword', payload: { password, data } });
|
||||
};
|
||||
|
||||
export const decodeWithPassword = (password: string, data: any): Promise<any> => {
|
||||
return callWorker({ type: 'decodeWithPassword', payload: { password, data } });
|
||||
};
|
||||
|
||||
export const encrypt = (data: string, publicKey: string): Promise<any> => {
|
||||
return callWorker({ type: 'encrypt', payload: { publicKey, data } });
|
||||
};
|
||||
|
||||
export const decrypt = (data: string, privateKey: string): Promise<any> => {
|
||||
return callWorker({ type: 'decrypt', payload: { privateKey, data } });
|
||||
};
|
||||
|
||||
export const chacha20Encrypt = (data: string): Promise<any> => {
|
||||
return callWorker({ type: 'chacha20Encrypt', payload: { data } });
|
||||
};
|
||||
|
||||
export const chacha20Decrypt = (ciphertext: string, nonce: string, key: string): Promise<any> => {
|
||||
return callWorker({ type: 'chacha20Decrypt', payload: { ciphertext, nonce, key } });
|
||||
};
|
||||
|
||||
export const generateKeyPairFromSeed = async (seed: string) => {
|
||||
const privateKey = sha256.create().update(seed).digest().toHex().toString();
|
||||
const publicKey = secp256k1.getPublicKey(Buffer.from(privateKey, "hex"), true);
|
||||
return {
|
||||
privateKey: privateKey,
|
||||
publicKey: Buffer.from(publicKey).toString('hex'),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateMd5 = async (data: string) => {
|
||||
const hash = md5.create();
|
||||
hash.update(data);
|
||||
return hash.digest().toHex();
|
||||
};
|
||||
|
||||
export const generateHashFromPrivateKey = async (privateKey: string) => {
|
||||
return sha256.create().update(privateKey + "rosetta").digest().toHex().toString();
|
||||
};
|
||||
|
||||
export const isEncodedWithPassword = (data: string) => {
|
||||
try {
|
||||
atob(data).split(":");
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -6,53 +6,35 @@ import * as secp256k1 from '@noble/secp256k1';
|
||||
|
||||
|
||||
self.onmessage = async (event: MessageEvent) => {
|
||||
const { action, data } = event.data;
|
||||
const { id, type, payload } = event.data;
|
||||
|
||||
switch (action) {
|
||||
case 'encodeWithPassword': {
|
||||
const { password, payload, task } = data;
|
||||
const result = await encodeWithPassword(password, payload);
|
||||
self.postMessage({ action: 'encodeWithPasswordResult', result, task });
|
||||
break;
|
||||
try {
|
||||
let result;
|
||||
switch (type) {
|
||||
case 'encodeWithPassword':
|
||||
result = await encodeWithPassword(payload.password, payload.data);
|
||||
break;
|
||||
case 'decodeWithPassword':
|
||||
result = await decodeWithPassword(payload.password, payload.data);
|
||||
break;
|
||||
case 'encrypt':
|
||||
result = await encrypt(payload.data, payload.publicKey);
|
||||
break;
|
||||
case 'decrypt':
|
||||
result = await decrypt(payload.data, payload.privateKey);
|
||||
break;
|
||||
case 'chacha20Encrypt':
|
||||
result = await chacha20Encrypt(payload.data);
|
||||
break;
|
||||
case 'chacha20Decrypt':
|
||||
result = await chacha20Decrypt(payload.ciphertext, payload.nonce, payload.key);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action: ${type}`);
|
||||
}
|
||||
case 'chacha20Encrypt': {
|
||||
const { payload, task } = data;
|
||||
const result = await chacha20Encrypt(payload);
|
||||
self.postMessage({ action: 'chacha20EncryptResult', result, task });
|
||||
break;
|
||||
}
|
||||
case 'chacha20Decrypt': {
|
||||
const { ciphertext, nonce, key, task } = data;
|
||||
const result = await chacha20Decrypt(ciphertext, nonce, key);
|
||||
self.postMessage({ action: 'chacha20DecryptResult', result, task });
|
||||
break;
|
||||
}
|
||||
case 'decodeWithPassword': {
|
||||
const { password, payload, task } = data;
|
||||
try{
|
||||
const result = await decodeWithPassword(password, payload);
|
||||
self.postMessage({ action: 'decodeWithPasswordResult', result, task });
|
||||
return;
|
||||
}catch(e){
|
||||
const result = null;
|
||||
self.postMessage({ action: 'decodeWithPasswordResult', result, task });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'decrypt': {
|
||||
const { payload: encryptedData, privateKey, task } = data;
|
||||
const result = await decrypt(encryptedData, privateKey);
|
||||
self.postMessage({ action: 'decryptResult', result, task });
|
||||
break;
|
||||
}
|
||||
case 'encrypt': {
|
||||
const { payload: plainData, publicKey, task } = data;
|
||||
const result = await encrypt(plainData, publicKey);
|
||||
self.postMessage({ action: 'encryptResult', result, task });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown action: ${action}`);
|
||||
self.postMessage({ id, ok: true, data: result });
|
||||
} catch (error) {
|
||||
self.postMessage({ id, ok: false, error: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
39
app/workers/image/image.ts
Normal file
39
app/workers/image/image.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// ...existing code...
|
||||
const worker = new Worker(new URL('./image.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
type WorkerReq =
|
||||
| { id: number; type: 'blurhashToBase64Image'; payload: { blurhash: string; width: number; height: number } }
|
||||
| { id: number; type: 'base64ImageToBlurhash'; payload: { base64Image: string } };
|
||||
|
||||
type WorkerRes =
|
||||
| { id: number; ok: true; data: string }
|
||||
| { id: number; ok: false; error: string };
|
||||
|
||||
let seq = 0;
|
||||
const pending = new Map<number, (res: WorkerRes) => void>();
|
||||
|
||||
worker.onmessage = (e: MessageEvent<WorkerRes>) => {
|
||||
const res = e.data;
|
||||
const cb = pending.get(res.id);
|
||||
if (cb) {
|
||||
pending.delete(res.id);
|
||||
cb(res);
|
||||
}
|
||||
};
|
||||
|
||||
function callWorker(req: Omit<WorkerReq, 'id'>): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
pending.set(id, (res) => (res.ok ? resolve(res.data) : reject(res.error)));
|
||||
worker.postMessage({ ...req, id });
|
||||
});
|
||||
}
|
||||
|
||||
export function blurhashToBase64Image(blurhash: string, width: number, height: number): Promise<string> {
|
||||
return callWorker({ type: 'blurhashToBase64Image', payload: { blurhash, width, height } });
|
||||
}
|
||||
|
||||
export function base64ImageToBlurhash(base64Image: string): Promise<string> {
|
||||
return callWorker({ type: 'base64ImageToBlurhash', payload: { base64Image } });
|
||||
}
|
||||
// ...existing code...
|
||||
71
app/workers/image/image.worker.ts
Normal file
71
app/workers/image/image.worker.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { decode, encode } from 'blurhash';
|
||||
|
||||
type Req =
|
||||
| { id: number; type: 'blurhashToBase64Image'; payload: { blurhash: string; width: number; height: number } }
|
||||
| { id: number; type: 'base64ImageToBlurhash'; payload: { base64Image: string } };
|
||||
|
||||
type Res =
|
||||
| { id: number; ok: true; data: string }
|
||||
| { id: number; ok: false; error: string };
|
||||
|
||||
const toBase64 = async (blurhash: string, width: number, height: number): Promise<string> => {
|
||||
const pixels = decode(blurhash, width, height);
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('No 2d context');
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
const buf = new Uint8Array(await blob.arrayBuffer());
|
||||
let bin = '';
|
||||
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
|
||||
return `data:image/png;base64,${btoa(bin)}`;
|
||||
};
|
||||
|
||||
const toBlurhash = async (base64Image: string): Promise<string> => {
|
||||
const src = base64Image?.trim();
|
||||
if (!src) throw new Error('Empty image data');
|
||||
|
||||
const resp = await fetch(src);
|
||||
const blob = await resp.blob();
|
||||
if (!blob.size) throw new Error('Image fetch returned empty blob');
|
||||
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const { width, height } = bitmap;
|
||||
if (!width || !height) {
|
||||
bitmap.close();
|
||||
throw new Error(`Image has invalid size ${width}x${height}`);
|
||||
}
|
||||
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
throw new Error('No 2d context');
|
||||
}
|
||||
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
bitmap.close();
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
return encode(imageData.data, imageData.width, imageData.height, 4, 4);
|
||||
};
|
||||
|
||||
self.onmessage = async (e: MessageEvent<Req>) => {
|
||||
const { id, type, payload } = e.data;
|
||||
const reply = (res: Res) => (self as unknown as Worker).postMessage(res);
|
||||
try {
|
||||
if (type === 'blurhashToBase64Image') {
|
||||
const data = await toBase64(payload.blurhash, payload.width, payload.height);
|
||||
reply({ id, ok: true, data });
|
||||
} else if (type === 'base64ImageToBlurhash') {
|
||||
const data = await toBlurhash(payload.base64Image);
|
||||
reply({ id, ok: true, data });
|
||||
} else {
|
||||
throw new Error(`Unknown type ${type}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
reply({ id, ok: false, error: String(err?.message ?? err) });
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
#/bin/bash
|
||||
echo "Using directory: $(pwd)"
|
||||
current_dir=$(pwd)
|
||||
# Run npm with a timeout using perl (cross-platform alternative to 'timeout')
|
||||
perl -e 'alarm shift; $SIG{ALRM}=sub{kill INT => -$$}; exec @ARGV' 10 npm run start
|
||||
|
||||
echo "Build complete. Packing service packs..."
|
||||
|
||||
APP_VERSION=$(grep -o 'APP_VERSION *= *"[^"]*' "$current_dir/app/version.ts" | sed 's/APP_VERSION *= *"//')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, shell, app, ipcMain, nativeTheme, screen, powerMonitor } from 'electron'
|
||||
import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor, app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import fs from 'fs'
|
||||
import { WORKING_DIR } from './constants';
|
||||
@@ -28,8 +28,8 @@ export function createPreloaderWindow() {
|
||||
|
||||
export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
width: 385,
|
||||
height: 555,
|
||||
minWidth: 385,
|
||||
minHeight: 555,
|
||||
show: false,
|
||||
@@ -45,7 +45,8 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
||||
nodeIntegrationInSubFrames: true,
|
||||
nodeIntegrationInWorker: true,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: true
|
||||
allowRunningInsecureContent: true,
|
||||
autoplayPolicy: 'no-user-gesture-required'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,17 +74,13 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
||||
}
|
||||
|
||||
export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
||||
let bounceId: number | null = null;
|
||||
ipcMain.removeAllListeners('window-resize');
|
||||
ipcMain.removeAllListeners('window-resizeble');
|
||||
ipcMain.removeAllListeners('window-theme');
|
||||
ipcMain.removeAllListeners("write-file");
|
||||
ipcMain.removeAllListeners("read-file");
|
||||
ipcMain.removeAllListeners("mkdir");
|
||||
ipcMain.removeHandler("get-core-version");
|
||||
ipcMain.removeHandler("get-arch");
|
||||
ipcMain.removeAllListeners("get-user-dir");
|
||||
ipcMain.removeHandler("get-downloads-path")
|
||||
ipcMain.removeHandler("get-app-path");
|
||||
ipcMain.removeHandler('open-dev-tools');
|
||||
ipcMain.removeHandler('window-state');
|
||||
ipcMain.removeHandler('window-toggle');
|
||||
@@ -91,14 +88,38 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
||||
ipcMain.removeHandler('window-minimize');
|
||||
ipcMain.removeHandler('showItemInFolder');
|
||||
ipcMain.removeHandler('openExternal');
|
||||
ipcMain.removeHandler('window-top');
|
||||
ipcMain.removeHandler('window-priority-normal');
|
||||
|
||||
ipcMain.handle('showItemInFolder', (_, fullPath: string) => {
|
||||
shell.showItemInFolder(fullPath);
|
||||
});
|
||||
ipcMain.handle('window-top', () => {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.setAlwaysOnTop(true, "screen-saver"); // самый высокий уровень
|
||||
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
|
||||
ipcMain.handle('openExternal', (_, url: string) => {
|
||||
shell.openExternal(url);
|
||||
});
|
||||
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', () => {
|
||||
if (mainWindow.webContents.isDevToolsOpened()) {
|
||||
@@ -208,27 +229,4 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.send("mkdir-reply");
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Change to get-core-version
|
||||
*/
|
||||
ipcMain.handle("get-core-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle("get-arch", () => {
|
||||
return process.arch;
|
||||
})
|
||||
|
||||
ipcMain.on("get-user-dir", () => {
|
||||
const userDir = app.getPath("userData");
|
||||
mainWindow.webContents.send("get-user-dir-reply", userDir);
|
||||
});
|
||||
|
||||
ipcMain.handle("get-app-path", () => {
|
||||
return app.getAppPath();
|
||||
});
|
||||
|
||||
ipcMain.handle("get-downloads-path", () => {
|
||||
return app.getPath("downloads");
|
||||
});
|
||||
}
|
||||
|
||||
34
lib/main/ipcs/ipcCore.ts
Normal file
34
lib/main/ipcs/ipcCore.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { app, ipcMain, shell } from "electron";
|
||||
|
||||
ipcMain.handle("ipcCore:getCoreVersion", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle("ipcCore:getArch", () => {
|
||||
return process.arch;
|
||||
})
|
||||
|
||||
ipcMain.handle("ipcCore:getUserDir", () => {
|
||||
const userDir = app.getPath("userData");
|
||||
return userDir;
|
||||
});
|
||||
|
||||
ipcMain.handle("ipcCore:getAppPath", () => {
|
||||
return app.getAppPath();
|
||||
});
|
||||
|
||||
ipcMain.handle("ipcCore:getDownloadsPath", () => {
|
||||
return app.getPath("downloads");
|
||||
});
|
||||
|
||||
ipcMain.handle('ipcCore:showItemInFolder', (_, fullPath: string) => {
|
||||
shell.showItemInFolder(fullPath);
|
||||
});
|
||||
|
||||
ipcMain.handle('ipcCore:openExternal', (_, url: string) => {
|
||||
shell.openExternal(url);
|
||||
});
|
||||
|
||||
ipcMain.handle('ipcCore:getPlatform', () => {
|
||||
return process.platform;
|
||||
});
|
||||
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");
|
||||
});
|
||||
175
lib/main/main.ts
175
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 { createAppWindow, startApplication } from './app'
|
||||
import './ipcs/ipcDatabase'
|
||||
@@ -7,96 +7,139 @@ import './ipcs/ipcFilestorage'
|
||||
import './ipcs/ipcUpdate'
|
||||
import './ipcs/ipcNotification'
|
||||
import './ipcs/ipcDevice'
|
||||
import { Tray } from 'electron/main'
|
||||
import './ipcs/ipcCore'
|
||||
import './ipcs/ipcRuntime'
|
||||
import { join } from 'path'
|
||||
import { Logger } from './logger'
|
||||
|
||||
let lockInstance = app.requestSingleInstanceLock();
|
||||
let tray : Tray | null = null;
|
||||
const size = process.platform === 'darwin' ? 18 : 22;
|
||||
const logger = Logger('main');
|
||||
const lockInstance = app.requestSingleInstanceLock()
|
||||
let tray: Tray | null = null
|
||||
const size = process.platform === 'darwin' ? 18 : 22
|
||||
const logger = Logger('main')
|
||||
|
||||
const icon = nativeImage
|
||||
.createFromPath(join(__dirname, '../../resources/R.png'))
|
||||
.resize({ width: size, height: size })
|
||||
|
||||
const icon = nativeImage.createFromPath(
|
||||
join(__dirname, '../../resources/R.png')
|
||||
).resize({ width: size, height: size });
|
||||
|
||||
if(!lockInstance){
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
if (!lockInstance) {
|
||||
app.quit()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.log(`main thread error, reason: ${reason}`);
|
||||
});
|
||||
logger.log(`main thread error, reason: ${reason}`)
|
||||
})
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
app.disableHardwareAcceleration()
|
||||
|
||||
app.on('second-instance', () => {
|
||||
// Someone tried to run a second instance, we should focus our window.
|
||||
const allWindows = BrowserWindow.getAllWindows();
|
||||
if (allWindows.length) {
|
||||
const mainWindow = allWindows[0];
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
if (mainWindow.isVisible() === false) mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
const allWindows = BrowserWindow.getAllWindows()
|
||||
if (allWindows.length) {
|
||||
const mainWindow = allWindows[0]
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
if (!mainWindow.isVisible()) mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
export const restoreApplicationAfterClickOnTrayOrDock = () => {
|
||||
const allWindows = BrowserWindow.getAllWindows();
|
||||
const allWindows = BrowserWindow.getAllWindows()
|
||||
if (allWindows.length > 0) {
|
||||
const mainWindow = allWindows[0];
|
||||
if (mainWindow.isMinimized()){
|
||||
mainWindow.restore();
|
||||
return;
|
||||
const mainWindow = allWindows[0]
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
return
|
||||
}
|
||||
if(mainWindow.isVisible() === false){
|
||||
mainWindow.show();
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
mainWindow.focus();
|
||||
mainWindow.focus()
|
||||
} 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 () => {
|
||||
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();
|
||||
electronApp.setAppUserModelId('Rosetta')
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
// Убираем File/View и оставляем только app + минимальный Edit (roles)
|
||||
if (process.platform === 'darwin') {
|
||||
const minimalMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'pasteAndMatchStyle' },
|
||||
{ role: 'delete' },
|
||||
{ role: 'selectAll' }
|
||||
]
|
||||
}
|
||||
])
|
||||
Menu.setApplicationMenu(minimalMenu)
|
||||
} else {
|
||||
Menu.setApplicationMenu(null)
|
||||
}
|
||||
|
||||
app.on('activate', function () {
|
||||
restoreApplicationAfterClickOnTrayOrDock();
|
||||
});
|
||||
tray = new Tray(icon)
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: 'Open App', click: () => restoreApplicationAfterClickOnTrayOrDock() },
|
||||
{ label: 'Quit', click: () => app.quit() }
|
||||
])
|
||||
tray.setContextMenu(contextMenu)
|
||||
tray.setToolTip('Rosetta')
|
||||
tray.on('click', () => {
|
||||
restoreApplicationAfterClickOnTrayOrDock()
|
||||
})
|
||||
|
||||
startApplication()
|
||||
|
||||
const isDevBuild =
|
||||
!app.isPackaged ||
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
Boolean(process.env.ELECTRON_RENDERER_URL)
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
// В production оставляем стандартную защиту шорткатов
|
||||
if (!isDevBuild) {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
return
|
||||
}
|
||||
|
||||
// В dev явно разрешаем Ctrl+R и Cmd+R для перезагрузки, так как в режиме разработки это часто нужно
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const key = input.key?.toLowerCase?.() ?? ''
|
||||
const isReload = input.type === 'keyDown' && (input.meta || input.control) && key === 'r'
|
||||
if (isReload) {
|
||||
event.preventDefault()
|
||||
window.webContents.reloadIgnoringCache()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
restoreApplicationAfterClickOnTrayOrDock()
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform == 'darwin') {
|
||||
app.hide();
|
||||
if (process.platform === 'darwin') {
|
||||
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;
|
||||
deviceName: string;
|
||||
deviceId: string;
|
||||
mediaApi: {
|
||||
getSoundUrl: (fileName: string) => Promise<string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,50 @@
|
||||
import { contextBridge, ipcRenderer, shell } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
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 () => {
|
||||
let version = await ipcRenderer.invoke("get-core-version");
|
||||
let appPath = await ipcRenderer.invoke("get-app-path");
|
||||
let arch = await ipcRenderer.invoke("get-arch");
|
||||
let deviceName = await ipcRenderer.invoke("device:name");
|
||||
let deviceId = await ipcRenderer.invoke("device:id");
|
||||
|
||||
let downloadsPath = await ipcRenderer.invoke("get-downloads-path");
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
contextBridge.exposeInMainWorld('version', version);
|
||||
contextBridge.exposeInMainWorld('platform', process.platform);
|
||||
contextBridge.exposeInMainWorld('appPath', appPath);
|
||||
contextBridge.exposeInMainWorld('arch', arch);
|
||||
contextBridge.exposeInMainWorld('deviceName', deviceName);
|
||||
contextBridge.exposeInMainWorld('deviceId', deviceId);
|
||||
contextBridge.exposeInMainWorld('shell', {
|
||||
openExternal: (url: string) => {
|
||||
ipcRenderer.invoke('openExternal', url);
|
||||
ipcRenderer.invoke('ipcCore:openExternal', url);
|
||||
},
|
||||
showItemInFolder: (fullPath: string) => {
|
||||
ipcRenderer.invoke('showItemInFolder', fullPath);
|
||||
ipcRenderer.invoke('ipcCore:showItemInFolder', fullPath);
|
||||
}
|
||||
});
|
||||
contextBridge.exposeInMainWorld("mediaApi", {
|
||||
getSoundUrl: async (fileName: string) => {
|
||||
return resolveSound(fileName);
|
||||
}
|
||||
});
|
||||
contextBridge.exposeInMainWorld('downloadsPath', downloadsPath)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
window.electron = electronAPI
|
||||
window.api = api
|
||||
window.version = version;
|
||||
window.platform = process.platform;
|
||||
window.appPath = appPath;
|
||||
window.arch = arch;
|
||||
window.api = api;
|
||||
window.shell = shell;
|
||||
window.downloadsPath = downloadsPath;
|
||||
window.deviceName = deviceName;
|
||||
window.deviceId = deviceId;
|
||||
window.mediaApi = {
|
||||
getSoundUrl: async (fileName: string) => {
|
||||
return resolveSound(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
package.json
37
package.json
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"name": "Rosetta",
|
||||
"version": "1.4.9",
|
||||
"version": "1.5.5",
|
||||
"description": "Rosetta Messenger",
|
||||
"main": "./out/main/main.js",
|
||||
"license": "MIT",
|
||||
"build": {
|
||||
"electronUpdaterCompatibility": false,
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "resources/",
|
||||
"to": "resources/"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"node_modules/sqlite3/**/*",
|
||||
"out/main/**/*",
|
||||
@@ -17,10 +23,9 @@
|
||||
"appId": "im.rosetta",
|
||||
"icon": "icons/mac/icon.icns",
|
||||
"target": [
|
||||
"zip",
|
||||
"pkg"
|
||||
],
|
||||
"artifactName": "/builds/darwin/${arch}/Rosetta-${version}.${ext}",
|
||||
"artifactName": "builds/darwin/${arch}/Rosetta-${version}.${ext}",
|
||||
"publish": null
|
||||
},
|
||||
"pkg": {
|
||||
@@ -34,15 +39,15 @@
|
||||
"icon": "icons/png/256x256.png",
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
],
|
||||
"artifactName": "builds/linux/${arch}/Rosetta-${version}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"icon": "icons/win/icon.ico",
|
||||
"target": [
|
||||
"zip",
|
||||
"nsis"
|
||||
],
|
||||
"artifactName": "/builds/win/${arch}/Rosetta-${version}.${ext}"
|
||||
"artifactName": "builds/win/${arch}/Rosetta-${version}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
@@ -60,11 +65,11 @@
|
||||
"start": "electron-vite preview",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "electron-vite build && electron-builder --dir",
|
||||
"kernel:win": "electron-vite build && electron-builder --win --x64",
|
||||
"kernel:win": "mkdir \"dist/builds/win/x64\" && electron-vite build && electron-builder --win --x64",
|
||||
"kernel:darwin-arm64": "electron-vite build && electron-builder --mac --arm64",
|
||||
"kernel:darwin-x64": "electron-vite build && electron-builder --mac --x64",
|
||||
"kernel:mac": "npm run kernel:darwin-x64 && npm run kernel:darwin-arm64",
|
||||
"kernel:linux": "electron-vite build && electron-builder --linux --x64 --arm64"
|
||||
"kernel:mac": "mkdir -p dist/builds/darwin/arm64 dist/builds/darwin/x64 && npm run kernel:darwin-x64 && npm run kernel:darwin-arm64",
|
||||
"kernel:linux": "mkdir -p dist/builds/linux/x64 dist/builds/linux/arm64 dist/builds/linux/x86_64 && electron-vite build && electron-builder --linux --x64 --arm64 && if [ -d dist/builds/linux/x86_64 ]; then mkdir -p dist/builds/linux/x64 && mv dist/builds/linux/x86_64/* dist/builds/linux/x64/ 2>/dev/null || true; rmdir dist/builds/linux/x86_64 2>/dev/null || true; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
@@ -79,9 +84,11 @@
|
||||
"@mantine/form": "^8.3.12",
|
||||
"@mantine/hooks": "^8.3.12",
|
||||
"@mantine/modals": "^8.3.12",
|
||||
"@noble/ciphers": "^1.2.1",
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
"@noble/secp256k1": "^3.0.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/diffie-hellman": "^5.0.3",
|
||||
"@types/elliptic": "^6.4.18",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/npm": "^7.19.3",
|
||||
@@ -91,7 +98,6 @@
|
||||
"bip39": "^3.1.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"elliptic": "^6.6.1",
|
||||
@@ -104,9 +110,13 @@
|
||||
"i": "^0.3.7",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"jszip": "^3.10.1",
|
||||
"libsodium": "^0.8.2",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"libsodium-wrappers-sumo": "^0.8.2",
|
||||
"lottie-react": "^2.4.1",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"npm": "^11.11.0",
|
||||
"pako": "^2.1.0",
|
||||
"react-router-dom": "^7.4.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
@@ -116,6 +126,8 @@
|
||||
"recharts": "^2.15.1",
|
||||
"sql.js": "^1.13.0",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"wa-sqlite": "^1.0.0",
|
||||
"web-bip39": "^0.0.3"
|
||||
},
|
||||
@@ -123,8 +135,10 @@
|
||||
"@electron-toolkit/eslint-config": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@electron/rebuild": "^4.0.3",
|
||||
"@rushstack/eslint-patch": "^1.10.5",
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -133,7 +147,6 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^38.3.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"@electron/rebuild": "^4.0.3",
|
||||
"electron-vite": "^3.0.0",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user