Compare commits

..

73 Commits

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

View File

@@ -51,24 +51,25 @@ jobs:
- name: NPM offline setup - name: NPM offline setup
shell: bash shell: bash
run: | run: |
mkdir -p dist/builds/darwin/arm64 dist/builds/darwin/x64
npm config set cache "$HOME/.npm-cache" --global npm config set cache "$HOME/.npm-cache" --global
npm config set prefer-offline true --global npm config set prefer-offline true --global
- name: Install npm dependencies - name: Install npm dependencies
run: npm install --prefer-offline --no-audit --no-fund run: npm install --prefer-offline --no-audit --no-fund
- name: Build the application (${{ matrix.arch }}) - name: Build the application
run: | run: |
npx electron-vite build npx electron-vite build
npx electron-builder --mac --${{ matrix.arch }} npx electron-builder --mac --${{ matrix.arch }}
- name: Check if files exist (${{ matrix.arch }}) - name: Check if files exist
run: | run: |
echo "=== Checking dist structure ===" echo "=== Checking dist structure ==="
find dist/builds/darwin/${{ matrix.arch }} -type f -name "*.pkg" 2>/dev/null || echo "No PKG files found" 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" ls -la dist/builds/darwin/${{ matrix.arch }}/ 2>/dev/null || echo "arch folder not found"
- name: Upload ${{ matrix.arch }} to SSH using SCP - name: Upload to SSH using SCP
uses: appleboy/scp-action@master uses: appleboy/scp-action@master
with: with:
host: ${{ secrets.SDU_SSH_HOST }} host: ${{ secrets.SDU_SSH_HOST }}

View File

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

View File

@@ -13,33 +13,12 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: '22' node-version: '22'
# Кэш npm (тарифы грузятся из ~/.npm-cache на macOS) - name: Checkout code
- name: Cache npm cache uses: actions/checkout@v6
uses: actions/cache@v5
with:
path: ${{ env.HOME }}/.npm-cache
key: ${{ runner.os }}-npm-${{ hashFiles('**/package.json') }}
restore-keys: |
${{ runner.os }}-npm-
if-no-files-found: ignore
# Кэш для electron-builder (Linux)
- name: Cache electron-builder (Linux)
uses: actions/cache@v5
with:
path: |
${{ env.HOME }}/.cache/electron-builder
${{ env.HOME }}/.cache/electron
key: ${{ runner.os }}-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
restore-keys: |
${{ runner.os }}-electron-builder-
if-no-files-found: ignore
- name: NPM offline setup - name: NPM offline setup
shell: bash shell: bash
@@ -88,8 +67,16 @@ jobs:
find packs -maxdepth 3 -type f 2>/dev/null || true 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; } test -n "$(find packs -type f -print -quit 2>/dev/null)" || { echo "packs is empty"; exit 1; }
- name: Install SCP in Docker container - name: Clean files before upload
run: apt-get install -y openssh-client 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 - name: Upload to SSH using SCP
uses: appleboy/scp-action@master uses: appleboy/scp-action@master
@@ -101,4 +88,4 @@ jobs:
source: "packs/*" source: "packs/*"
target: "${{ secrets.SDU_SSH_PACKS }}" target: "${{ secrets.SDU_SSH_PACKS }}"
strip_components: 1 strip_components: 1
rm: true rm: false

View File

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

View File

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

View File

@@ -135,7 +135,7 @@ export function ChatHeader() {
</Flex> </Flex>
</Flex> </Flex>
<Flex h={'100%'} align={'center'} gap={'sm'}> <Flex h={'100%'} align={'center'} gap={'sm'}>
{publicKey != opponent.publicKey && ( {publicKey != opponent.publicKey && !isSystemAccount && (
<IconPhone <IconPhone
onClick={() => call(dialog)} onClick={() => call(dialog)}
style={{ style={{

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { AttachmentError } from "../AttachmentError/AttachmentError";
import { MessageAvatar } from "./MessageAvatar"; import { MessageAvatar } from "./MessageAvatar";
import { MessageProps } from "../Messages/Message"; import { MessageProps } from "../Messages/Message";
import { MessageCall } from "./MessageCall"; import { MessageCall } from "./MessageCall";
import { MessageVoice } from "./MessageVoice";
export interface MessageAttachmentsProps { export interface MessageAttachmentsProps {
attachments: Attachment[]; attachments: Attachment[];
@@ -54,6 +55,8 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
return <MessageAvatar {...attachProps} key={index}></MessageAvatar> return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
case AttachmentType.CALL: case AttachmentType.CALL:
return <MessageCall {...attachProps} key={index}></MessageCall> return <MessageCall {...attachProps} key={index}></MessageCall>
case AttachmentType.VOICE:
return <MessageVoice {...attachProps} key={index}></MessageVoice>
default: default:
return <AttachmentError key={index}></AttachmentError>; return <AttachmentError key={index}></AttachmentError>;
} }

View File

@@ -2,8 +2,9 @@ import { useAttachment } from "@/app/providers/AttachmentProvider/useAttachment"
import { AttachmentProps } from "./MessageAttachments"; import { AttachmentProps } from "./MessageAttachments";
import { Avatar, Box, Flex, Text } from "@mantine/core"; import { Avatar, Box, Flex, Text } from "@mantine/core";
import { useRosettaColors } from "@/app/hooks/useRosettaColors"; import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { IconPhoneOutgoing, IconX } from "@tabler/icons-react"; import { IconPhoneIncoming, IconPhoneOutgoing, IconX } from "@tabler/icons-react";
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime"; import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
export function MessageCall(props: AttachmentProps) { export function MessageCall(props: AttachmentProps) {
const { const {
@@ -13,9 +14,10 @@ export function MessageCall(props: AttachmentProps) {
props.attachment, props.attachment,
props.parent, props.parent,
); );
const publicKey = usePublicKey();
const preview = getPreview(); const preview = getPreview();
const callerRole = preview.split("::")[0]; const caller = props.parent.from == publicKey;
const duration = parseInt(preview.split("::")[1]); const duration = parseInt(preview);
const colors = useRosettaColors(); const colors = useRosettaColors();
const error = duration == 0; const error = duration == 0;
@@ -30,13 +32,11 @@ export function MessageCall(props: AttachmentProps) {
<Flex gap={'sm'} direction={'row'}> <Flex gap={'sm'} direction={'row'}>
<Avatar bg={error ? colors.error : colors.brandColor} size={40}> <Avatar bg={error ? colors.error : colors.brandColor} size={40}>
{!error && <> {!error && <>
{callerRole == "0" && ( {!caller && (
<IconPhoneOutgoing color={'white'} size={22}></IconPhoneOutgoing> <IconPhoneIncoming color={'white'} size={22}></IconPhoneIncoming>
)} )}
{callerRole == "1" && ( {caller && (
<IconPhoneOutgoing color={'white'} size={22} style={{ <IconPhoneOutgoing color={'white'} size={22}></IconPhoneOutgoing>
transform: 'rotate(180deg)'
}}></IconPhoneOutgoing>
)} )}
</>} </>}
{error && <> {error && <>
@@ -45,7 +45,7 @@ export function MessageCall(props: AttachmentProps) {
</Avatar> </Avatar>
<Flex direction={'column'} gap={5}> <Flex direction={'column'} gap={5}>
<Text size={'sm'}>{ <Text size={'sm'}>{
error ? (callerRole == "0" ? "Missed call" : "Rejected call") : (callerRole == "0" ? "Incoming call" : "Outgoing call") error ? (!caller ? "Missed call" : "Rejected call") : (!caller ? "Incoming call" : "Outgoing call")
}</Text> }</Text>
{!error && {!error &&
<Text size={'xs'} c={colors.chevrons.active}> <Text size={'xs'} c={colors.chevrons.active}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,16 +9,18 @@ import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useDatabase } from "../DatabaseProvider/useDatabase"; import { useDatabase } from "../DatabaseProvider/useDatabase";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger"; import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { useDialogsCache } from "../DialogProvider/useDialogsCache"; import { useDialogsCache } from "../DialogProvider/useDialogsCache";
import { DialogContext } from "../DialogProvider/DialogProvider"; import { AttachmentMeta, DialogContext } from "../DialogProvider/DialogProvider";
import { useTransportServer } from "../TransportProvider/useTransportServer";
export function usePrepareAttachment() { export function usePrepareAttachment() {
const intervalsRef = useRef<NodeJS.Timeout>(null); const intervalsRef = useRef<NodeJS.Timeout>(null);
const {uploadFile} = useTransport(); const {uploadFile} = useTransport();
const {updateDialog} = useDialogsList(); const {updateDialog} = useDialogsList();
const {runQuery} = useDatabase(); const {runQuery, getQuery} = useDatabase();
const {info} = useConsoleLogger('usePrepareAttachment'); const {info, error} = useConsoleLogger('usePrepareAttachment');
const {getDialogCache} = useDialogsCache(); const {getDialogCache} = useDialogsCache();
const context = useContext(DialogContext); const context = useContext(DialogContext);
const transportServer = useTransportServer();
const updateTimestampInDialogCache = (dialog : string, message_id: string) => { const updateTimestampInDialogCache = (dialog : string, message_id: string) => {
const dialogCache = getDialogCache(dialog); const dialogCache = getDialogCache(dialog);
@@ -33,6 +35,79 @@ export function usePrepareAttachment() {
} }
} }
/**
* Обновляет транспортный сервер в кэше, чтобы поддерживать его в актуальном состоянии после загрузки
*/
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);
}
/** /**
* Обновляет временную метку в сообщении, пока вложения отправляются, * Обновляет временную метку в сообщении, пока вложения отправляются,
* потому что если этого не делать, то сообщение может быть помечено как * потому что если этого не делать, то сообщение может быть помечено как
@@ -74,18 +149,6 @@ export function usePrepareAttachment() {
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000); }, (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("::");
}
/** /**
* Подготавливает вложения для отправки. Подготовка * Подготавливает вложения для отправки. Подготовка
* состоит в загрузке файлов на транспортный сервер, мы не делаем * состоит в загрузке файлов на транспортный сервер, мы не делаем
@@ -93,7 +156,7 @@ export function usePrepareAttachment() {
* а так же из-за надежности доставки файлов через HTTP * а так же из-за надежности доставки файлов через HTTP
* @param attachments Attachments to prepare for sending * @param attachments Attachments to prepare for sending
*/ */
const prepareAttachmentsToSend = async (message_id: string, dialog: string, password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => { const prepareAttachmentsToSend = async (message_id: string, dialog: string, password: string, attachments : Attachment[]) : Promise<Attachment[]> => {
if(attachments.length <= 0){ if(attachments.length <= 0){
return []; return [];
} }
@@ -103,14 +166,17 @@ export function usePrepareAttachment() {
const attachment : Attachment = attachments[i]; const attachment : Attachment = attachments[i];
if(attachment.type == AttachmentType.CALL){ if(attachment.type == AttachmentType.CALL){
/** /**
* Звонок загружать не надо * Звонк загружать не надо, по этому просто отправляем его как есть, там нет blob
*/ */
prepared.push(attachment);
continue; continue;
} }
if(attachment.type == AttachmentType.MESSAGES){ if(attachment.type == AttachmentType.MESSAGES){
let reply : MessageReply[] = JSON.parse(attachment.blob) let reply : MessageReply[] = JSON.parse(attachment.blob);
for(let j = 0; j < reply.length; j++){ for(let j = 0; j < reply.length; j++){
reply[j].attachments = await prepareAttachmentsToSend(message_id, dialog, password, reply[j].attachments, true); for(let k = 0; k < reply[j].attachments.length; k++){
reply[j].attachments[k].blob = "";
}
} }
prepared.push({ prepared.push({
...attachment, ...attachment,
@@ -131,16 +197,30 @@ export function usePrepareAttachment() {
const upid = attachment.id; const upid = attachment.id;
info(`Uploading attachment with upid: ${upid}`); info(`Uploading attachment with upid: ${upid}`);
info(`Attachment content length: ${content.length}`); info(`Attachment content length: ${content.length}`);
let tag = await uploadFile(upid, content); let tag = await uploadFile(upid, content).catch(() => {
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`); error(`Network error while uploading attachment ${upid}`);
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
}
prepared.push({
...attachment,
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
blob: ""
}); });
if(!tag){
/**
* При ошибке загрузки по сети
*/
stopUpdateTimeInUpAttachment();
console.info("stop upd")
continue;
}
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
stopUpdateTimeInUpAttachment();
const preparedAttachment : Attachment = {
...attachment,
transport: {
transport_server: transportServer || "",
transport_tag: tag
},
preview: attachment.preview,
blob: ""
};
await updateTransportAfterUploading(dialog, message_id, preparedAttachment);
prepared.push(preparedAttachment);
} }
return prepared; return prepared;
}catch(e){ }catch(e){
@@ -148,6 +228,12 @@ export function usePrepareAttachment() {
} }
} }
const stopUpdateTimeInUpAttachment = () => {
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
}
}
return { return {
prepareAttachmentsToSend prepareAttachmentsToSend
} }

View File

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

View File

@@ -1,7 +1,7 @@
import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto'; import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto';
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase'; import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
import { createContext, useEffect, useRef, useState } from 'react'; import { createContext, useEffect, useRef, useState } from 'react';
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message'; import { Attachment, AttachmentTransport, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
import { usePrivatePlain } from '../AccountProvider/usePrivatePlain'; import { usePrivatePlain } from '../AccountProvider/usePrivatePlain';
import { usePublicKey } from '../AccountProvider/usePublicKey'; import { usePublicKey } from '../AccountProvider/usePublicKey';
import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read'; import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read';
@@ -46,6 +46,7 @@ export interface AttachmentMeta {
id: string; id: string;
type: AttachmentType; type: AttachmentType;
preview: string; preview: string;
transport: AttachmentTransport;
} }
export interface Message { export interface Message {
@@ -214,26 +215,40 @@ export function DialogProvider(props: DialogProviderProps) {
readUpdated = true; readUpdated = true;
} }
let decryptKey = ''; let decryptKey = '';
if(message.from_me && message.chacha_key != "" && !message.chacha_key.startsWith("sync:")){
/**
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key
*/
try{
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key), 'binary').toString('hex');
}catch(e) {
decryptKey = "";
}
}
if(message.from_me && message.chacha_key != "" && message.chacha_key.startsWith("sync:")){ if(message.from_me && message.chacha_key != "" && message.chacha_key.startsWith("sync:")){
/** /**
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это * Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это
* сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет, * сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет,
* значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (plain_message) * значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (plain_message)
*/ */
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('utf-8'); decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('hex');
} }
if(hasGroup(props.dialog)){ if(hasGroup(props.dialog)){
/** /**
* Если это групповое сообщение, то получаем ключ группы * Если это групповое сообщение, то получаем ключ группы
*/ */
decryptKey = await getGroupKey(props.dialog); decryptKey = await getGroupKey(props.dialog);
/**
* Приводим к HEX так как этого требует формат расшифровки вложений в приложении
*/
decryptKey = Buffer.from(decryptKey).toString('hex');
} }
if(!message.from_me && !hasGroup(props.dialog)){ if(!message.from_me && !hasGroup(props.dialog)){
/** /**
* Если сообщение не от меня и не групповое, * Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом * расшифровываем ключ чачи своим приватным ключом
*/ */
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8'); decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
} }
finalMessages.push({ finalMessages.push({
from_public_key: message.from_public_key, from_public_key: message.from_public_key,
@@ -469,10 +484,8 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) { for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i]; const attachment = packet.getAttachments()[i];
attachments.push({ attachments.push({
id: attachment.id, ...attachment,
preview: attachment.preview, blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
}); });
} }
@@ -482,7 +495,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content, content: content,
timestamp: timestamp, timestamp: timestamp,
readed: 0, //сообщение прочитано readed: 0, //сообщение прочитано
chacha_key: chachaDecryptedKey.toString('utf-8'), chacha_key: chachaDecryptedKey.toString('hex'),
from_me: 1, //сообщение от нас from_me: 1, //сообщение от нас
plain_message: (decryptedContent as string), plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED, delivered: DeliveredMessageState.DELIVERED,
@@ -549,9 +562,7 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) { for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i]; const attachment = packet.getAttachments()[i];
attachments.push({ attachments.push({
id: attachment.id, ...attachment,
preview: attachment.preview,
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : "" blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
}); });
} }
@@ -562,7 +573,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content, content: content,
timestamp: timestamp, timestamp: timestamp,
readed: 0, readed: 0,
chacha_key: groupKey, chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: 1, from_me: 1,
plain_message: decryptedContent, plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED, delivered: DeliveredMessageState.DELIVERED,
@@ -627,20 +638,18 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) { for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i]; const attachment = packet.getAttachments()[i];
attachments.push({ attachments.push({
id: attachment.id, ...attachment,
preview: attachment.preview, blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
}); });
} }
console.info(attachments);
const newMessage : Message = { const newMessage : Message = {
from_public_key: fromPublicKey, from_public_key: fromPublicKey,
to_public_key: toPublicKey, to_public_key: toPublicKey,
content: content, content: content,
timestamp: timestamp, timestamp: timestamp,
readed: idle ? 0 : 1, readed: idle ? 0 : 1,
chacha_key: chachaDecryptedKey.toString('utf-8'), chacha_key: chachaDecryptedKey.toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0, from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: (decryptedContent as string), plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED, delivered: DeliveredMessageState.DELIVERED,
@@ -707,9 +716,7 @@ export function DialogProvider(props: DialogProviderProps) {
for(let i = 0; i < packet.getAttachments().length; i++) { for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i]; const attachment = packet.getAttachments()[i];
attachments.push({ attachments.push({
id: attachment.id, ...attachment,
preview: attachment.preview,
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : "" blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
}); });
} }
@@ -720,7 +727,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content, content: content,
timestamp: timestamp, timestamp: timestamp,
readed: idle ? 0 : 1, readed: idle ? 0 : 1,
chacha_key: groupKey, chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0, from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: decryptedContent, plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED, delivered: DeliveredMessageState.DELIVERED,
@@ -794,7 +801,7 @@ export function DialogProvider(props: DialogProviderProps) {
* Если сообщение не от меня и не групповое, * Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом * расшифровываем ключ чачи своим приватным ключом
*/ */
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8'); decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
} }
finalMessages.push({ finalMessages.push({
from_public_key: message.from_public_key, from_public_key: message.from_public_key,
@@ -879,7 +886,7 @@ export function DialogProvider(props: DialogProviderProps) {
* Если сообщение не от меня и не групповое, * Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом * расшифровываем ключ чачи своим приватным ключом
*/ */
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8'); decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
} }
finalMessages.push({ finalMessages.push({
from_public_key: message.from_public_key, from_public_key: message.from_public_key,
@@ -964,7 +971,8 @@ export function DialogProvider(props: DialogProviderProps) {
id: meta.id, id: meta.id,
blob: blob, blob: blob,
type: meta.type, type: meta.type,
preview: meta.preview preview: meta.preview,
transport: meta.transport
}); });
} }
return attachments; return attachments;

View File

@@ -106,7 +106,8 @@ export function useDeattachedSender() {
attachmentsMeta.push({ attachmentsMeta.push({
id: attachment.id, id: attachment.id,
type: attachment.type, type: attachment.type,
preview: attachment.preview preview: attachment.preview,
transport: attachment.transport
}); });
if(attachment.type == AttachmentType.FILE){ if(attachment.type == AttachmentType.FILE){
/** /**
@@ -132,7 +133,7 @@ export function useDeattachedSender() {
|| publicKey == dialog) { || publicKey == dialog) {
return; return;
} }
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts); let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){ if(attachemnts.length <= 0 && message.trim() == ""){
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]); runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog); updateDialog(dialog);

View File

@@ -96,14 +96,13 @@ export function useDialog() : {
* же сообщений (смотреть problem_sync.md) * же сообщений (смотреть problem_sync.md)
*/ */
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary')); const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
setMessages((prev : Message[]) => ([...prev, { setMessages((prev : Message[]) => ([...prev, {
from_public_key: publicKey, from_public_key: publicKey,
to_public_key: dialog, to_public_key: dialog,
content: content, content: content,
timestamp: Date.now(), timestamp: Date.now(),
readed: publicKey == dialog ? 1 : 0, readed: publicKey == dialog ? 1 : 0,
chacha_key: "", chacha_key: key.toString('hex'),
from_me: 1, from_me: 1,
plain_message: message, plain_message: message,
delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING, delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
@@ -118,7 +117,8 @@ export function useDialog() : {
attachmentsMeta.push({ attachmentsMeta.push({
id: attachment.id, id: attachment.id,
type: attachment.type, type: attachment.type,
preview: attachment.preview preview: attachment.preview,
transport: attachment.transport
}); });
if(attachment.type == AttachmentType.FILE){ if(attachment.type == AttachmentType.FILE){
/** /**
@@ -135,7 +135,7 @@ export function useDialog() : {
await runQuery(` await runQuery(`
INSERT INTO messages INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) (from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : ( `, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, aesChachaKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING
), JSON.stringify(attachmentsMeta)]); ), JSON.stringify(attachmentsMeta)]);
updateDialog(dialog); updateDialog(dialog);
@@ -145,10 +145,13 @@ export function useDialog() : {
return; return;
} }
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
console.info("Sending key for message ", key.toString('hex')); console.info("Sending key for message ", key.toString('hex'));
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts); console.info(attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){ let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
if(preparedToNetworkSendAttachements.length < attachemnts.length){
/**
* Если не удалось нормально загрузить все вложения - тогда не отправляем сообщение
*/
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]); runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog); updateDialog(dialog);
return; return;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "Rosetta", "name": "Rosetta",
"version": "1.5.3", "version": "1.5.5",
"description": "Rosetta Messenger", "description": "Rosetta Messenger",
"main": "./out/main/main.js", "main": "./out/main/main.js",
"license": "MIT", "license": "MIT",