Merge branch 'dev' into main
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
name: MacOS Kernel Build
|
||||
run-name: Build and Upload MacOS Kernel
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
|
||||
on:
|
||||
@@ -12,6 +13,10 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -30,6 +35,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Кэш для electron-builder
|
||||
- name: Cache electron-builder
|
||||
uses: actions/cache@v5
|
||||
@@ -41,32 +47,35 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-electron-builder-
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: NPM offline setup
|
||||
shell: bash
|
||||
run: |
|
||||
npm config set cache "$HOME/.npm-cache" --global
|
||||
npm config set prefer-offline true --global
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm install --prefer-offline --no-audit --no-fund
|
||||
- name: Build the application
|
||||
run: npm run kernel:mac
|
||||
#Загружаем на удаленный сервер по SSH используя scp и пароль из секретов
|
||||
#Загружаем из двух папок dist/builds/darwin/x64 и dist/builds/darwin/arm64, так как electron-builder может создавать разные файлы для разных архитектур
|
||||
#Вызываем файл sshupload.sh и передаем ему параметры из секретов, чтобы не хранить пароль в открытом виде в workflow
|
||||
- name: Upload to SSH using scp
|
||||
shell: bash
|
||||
|
||||
- name: Build the application (${{ matrix.arch }})
|
||||
run: |
|
||||
chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
|
||||
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
|
||||
-l "$GITHUB_WORKSPACE/dist/builds/darwin/x64/Rosetta-*.pkg" \
|
||||
-r "${{ secrets.SDU_SSH_KERNEL }}/darwin/x64" \
|
||||
-s "${{ secrets.SDU_SSH_HOST }}" \
|
||||
-u "${{ secrets.SDU_SSH_USERNAME }}" \
|
||||
-p '${{ secrets.SDU_SSH_PASSWORD }}'
|
||||
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
|
||||
-l "$GITHUB_WORKSPACE/dist/builds/darwin/arm64/Rosetta-*.pkg" \
|
||||
-r "${{ secrets.SDU_SSH_KERNEL }}/darwin/arm64" \
|
||||
-s "${{ secrets.SDU_SSH_HOST }}" \
|
||||
-u "${{ secrets.SDU_SSH_USERNAME }}" \
|
||||
-p '${{ secrets.SDU_SSH_PASSWORD }}'
|
||||
|
||||
npx electron-vite build
|
||||
npx electron-builder --mac --${{ matrix.arch }}
|
||||
|
||||
- name: Check if files exist (${{ matrix.arch }})
|
||||
run: |
|
||||
echo "=== Checking dist structure ==="
|
||||
find dist/builds/darwin/${{ matrix.arch }} -type f -name "*.pkg" 2>/dev/null || echo "No PKG files found"
|
||||
ls -la dist/builds/darwin/${{ matrix.arch }}/ 2>/dev/null || echo "arch folder not found"
|
||||
|
||||
- name: Upload ${{ matrix.arch }} to SSH using SCP
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SDU_SSH_HOST }}
|
||||
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||
port: 22
|
||||
source: "dist/builds/darwin/${{ matrix.arch }}/Rosetta-*.pkg"
|
||||
target: "${{ secrets.SDU_SSH_KERNEL }}/darwin/${{ matrix.arch }}"
|
||||
strip_components: 4
|
||||
rm: true
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Linux Kernel Build
|
||||
run-name: Build and Upload Linux Kernel
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions
|
||||
on:
|
||||
@@ -11,7 +12,11 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -48,38 +53,28 @@ jobs:
|
||||
- name: Install npm dependencies
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Build the application
|
||||
run: npm run kernel:linux
|
||||
- name: Build the application (${{ matrix.arch }})
|
||||
run: |
|
||||
npx electron-vite build
|
||||
npx electron-builder --linux --${{ matrix.arch }}
|
||||
|
||||
- name: Check if files exist
|
||||
- name: Check if files exist (${{ matrix.arch }})
|
||||
run: |
|
||||
echo "=== Checking dist structure ==="
|
||||
find dist/builds -type f -name "*.AppImage" 2>/dev/null || echo "No AppImage files found"
|
||||
ls -la dist/builds/linux/ 2>/dev/null || echo "linux folder not found"
|
||||
find dist/builds/linux/${{ matrix.arch }} -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"
|
||||
|
||||
- name: Install SCP in Docker container
|
||||
run: apt-get install -y openssh-client
|
||||
|
||||
- name: Upload x64 to SSH using SCP
|
||||
- name: Upload ${{ matrix.arch }} to SSH using SCP
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SDU_SSH_HOST }}
|
||||
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||
port: 22
|
||||
source: "dist/builds/linux/x64/Rosetta-*.AppImage"
|
||||
target: "${{ secrets.SDU_SSH_KERNEL }}/linux/x64"
|
||||
source: "dist/builds/linux/${{ matrix.arch }}/Rosetta-*.AppImage"
|
||||
target: "${{ secrets.SDU_SSH_KERNEL }}/linux/${{ matrix.arch }}"
|
||||
strip_components: 4
|
||||
rm: true
|
||||
|
||||
- name: Upload arm64 to SSH using SCP
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SDU_SSH_HOST }}
|
||||
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||
port: 22
|
||||
source: "dist/builds/linux/arm64/Rosetta-*.AppImage"
|
||||
target: "${{ secrets.SDU_SSH_KERNEL }}/linux/arm64"
|
||||
strip_components: 4
|
||||
rm: true
|
||||
rm: true
|
||||
@@ -1,6 +1,6 @@
|
||||
name: SP Builds
|
||||
run-name: Build and Upload SP Packages
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -29,13 +29,13 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
if-no-files-found: ignore
|
||||
# Кэш для electron-builder (macOS)
|
||||
- name: Cache electron-builder (macOS)
|
||||
# Кэш для electron-builder (Linux)
|
||||
- name: Cache electron-builder (Linux)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ env.HOME }}/Library/Caches/electron-builder
|
||||
${{ env.HOME }}/Library/Caches/electron
|
||||
${{ env.HOME }}/.cache/electron-builder
|
||||
${{ env.HOME }}/.cache/electron
|
||||
key: ${{ runner.os }}-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-electron-builder-
|
||||
@@ -54,6 +54,11 @@ jobs:
|
||||
- name: Build the application
|
||||
run: npm run kernel:linux
|
||||
|
||||
- name: Install ZIP in Docker container
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y zip
|
||||
|
||||
#Собираем сервисные пакеты для всех платформ
|
||||
- name: Build SP
|
||||
shell: bash
|
||||
@@ -62,13 +67,28 @@ jobs:
|
||||
sh "$GITHUB_WORKSPACE/build-packs.sh"
|
||||
#Загружаем на удаленный сервер по SSH используя scp и пароль из секретов
|
||||
#Загружаем из двух папок dist/builds/darwin/x64 и dist/builds/darwin/arm64, так как electron-builder может создавать разные файлы для разных архитектур
|
||||
- name: Upload to SSH
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
|
||||
sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
|
||||
-l "$GITHUB_WORKSPACE/packs/*" \
|
||||
-r "${{ secrets.SDU_SSH_PACKS }}" \
|
||||
-s "${{ secrets.SDU_SSH_HOST }}" \
|
||||
-u "${{ secrets.SDU_SSH_USERNAME }}" \
|
||||
-p '${{ secrets.SDU_SSH_PASSWORD }}'
|
||||
# - name: Upload to SSH
|
||||
# shell: bash
|
||||
# run: |
|
||||
# chmod +x "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh"
|
||||
# sh "$GITHUB_WORKSPACE/.gitea/workflows/sshupload.sh" \
|
||||
# -l "$GITHUB_WORKSPACE/packs/*" \
|
||||
# -r "${{ secrets.SDU_SSH_PACKS }}" \
|
||||
# -s "${{ secrets.SDU_SSH_HOST }}" \
|
||||
# -u "${{ secrets.SDU_SSH_USERNAME }}" \
|
||||
# -p '${{ secrets.SDU_SSH_PASSWORD }}'
|
||||
|
||||
- name: Install SCP in Docker container
|
||||
run: apt-get install -y openssh-client
|
||||
|
||||
- name: Upload ${{ matrix.arch }} to SSH using SCP
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SDU_SSH_HOST }}
|
||||
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||
port: 22
|
||||
source: "$GITHUB_WORKSPACE/packs/*"
|
||||
target: "${{ secrets.SDU_SSH_PACKS }}"
|
||||
strip_components: 1
|
||||
rm: true
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: sshupload.sh -l <local_glob> -r <remote_dir> -s <server> -u <user> -p <password>
|
||||
EOF
|
||||
}
|
||||
|
||||
local_glob=""
|
||||
remote_dir=""
|
||||
server=""
|
||||
user=""
|
||||
password=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-l|--local) local_glob="$2"; shift 2;;
|
||||
-r|--remote) remote_dir="$2"; shift 2;;
|
||||
-s|--server) server="$2"; shift 2;;
|
||||
-u|--user) user="$2"; shift 2;;
|
||||
-p|--password) password="$2"; shift 2;;
|
||||
-h|--help) usage; exit 0;;
|
||||
*) echo "Unknown arg: $1" >&2; usage; exit 1;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$local_glob" || -z "$remote_dir" || -z "$server" || -z "$user" || -z "$password" ]]; then
|
||||
echo "Missing required params" >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure sshpass installed
|
||||
if ! command -v sshpass >/dev/null 2>&1; then
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
brew update
|
||||
brew install hudochenkov/sshpass/sshpass
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y sshpass
|
||||
else
|
||||
echo "sshpass not found and no supported package manager" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
user_host="${user}@${server}"
|
||||
|
||||
# Ensure remote dir exists and clear it
|
||||
sshpass -p "$password" ssh -o StrictHostKeyChecking=no "$user_host" "mkdir -p '$remote_dir' && rm -f '$remote_dir'/*"
|
||||
|
||||
# Expand glob (supports ~ and patterns) and upload each file (compatible with macOS bash 3.x)
|
||||
shopt -s nullglob
|
||||
eval "files=( ${local_glob} )"
|
||||
shopt -u nullglob
|
||||
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "No files matched: $local_glob" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for f in "${files[@]}"; do
|
||||
sshpass -p "$password" scp -o StrictHostKeyChecking=no "$f" "$user_host:$remote_dir/"
|
||||
done
|
||||
|
||||
echo "Upload completed"
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Windows Kernel Build
|
||||
run-name: Build and Upload Windows Kernel
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build Windows
|
||||
#Или если есть коммпит в папку lib в ветке main
|
||||
|
||||
@@ -6,9 +6,8 @@ import { ConfirmSeed } from './views/ConfirmSeed/ConfirmSeed';
|
||||
import { SetPassword } from './views/SetPassword/SetPassword';
|
||||
import { Main } from './views/Main/Main';
|
||||
import { ExistsSeed } from './views/ExistsSeed/ExistsSeed';
|
||||
import { Box, Divider } from '@mantine/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import './style.css'
|
||||
import { useRosettaColors } from './hooks/useRosettaColors';
|
||||
import { Buffer } from 'buffer';
|
||||
import { InformationProvider } from './providers/InformationProvider/InformationProvider';
|
||||
import { BlacklistProvider } from './providers/BlacklistProvider/BlacklistProvider';
|
||||
@@ -27,8 +26,6 @@ window.Buffer = Buffer;
|
||||
|
||||
export default function App() {
|
||||
const { allAccounts, accountProviderLoaded } = useAccountProvider();
|
||||
const colors = useRosettaColors();
|
||||
|
||||
|
||||
const getViewByLoginState = () => {
|
||||
if (!accountProviderLoaded) {
|
||||
@@ -59,7 +56,6 @@ export default function App() {
|
||||
<SystemAccountProvider>
|
||||
<Box h={'100%'}>
|
||||
<Topbar></Topbar>
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
<ContextMenuProvider>
|
||||
<ImageViwerProvider>
|
||||
<AvatarProvider>
|
||||
|
||||
@@ -60,6 +60,7 @@ export function ActionAvatar(props : ActionAvatarProps) {
|
||||
size={120}
|
||||
radius={120}
|
||||
mx="auto"
|
||||
bg={'#fff'}
|
||||
name={props.title.trim() || props.publicKey}
|
||||
color={'initials'}
|
||||
src={avatars.length > 0 ?
|
||||
|
||||
42
app/components/ActiveCall/ActiveCall.module.css
Normal file
42
app/components/ActiveCall/ActiveCall.module.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.active {
|
||||
background: linear-gradient(90deg,rgba(0, 186, 59, 1) 0%, rgba(0, 194, 81, 1) 50%);
|
||||
background-size: 200% 200%;
|
||||
animation: activeFlow 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes activeFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
filter: saturate(1.15);
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
}
|
||||
|
||||
.connecting {
|
||||
background: linear-gradient(120deg, #ff2d2d, #ff7a00, #ff2d2d);
|
||||
background-size: 220% 220%;
|
||||
animation: connectingFlow 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes connectingFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
filter: saturate(1.15);
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
}
|
||||
/* ...existing code... */
|
||||
98
app/components/ActiveCall/ActiveCall.tsx
Normal file
98
app/components/ActiveCall/ActiveCall.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCalls } from "@/app/providers/CallProvider/useCalls";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { Box, Flex, Loader, Text } from "@mantine/core";
|
||||
import classes from "./ActiveCall.module.css";
|
||||
import { CallState } from "@/app/providers/CallProvider/CallProvider";
|
||||
import { IconMicrophone, IconMicrophoneOff, IconPhoneX, IconVolume, IconVolumeOff } from "@tabler/icons-react";
|
||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||
|
||||
export function ActiveCall() {
|
||||
const {activeCall, callState, duration, muted, sound, close, setMuted, setSound, setShowCallView} = useCalls();
|
||||
const [userInfo] = useUserInformation(activeCall);
|
||||
//const colors = useRosettaColors();
|
||||
|
||||
if(activeCall == ""){
|
||||
return <></>
|
||||
}
|
||||
|
||||
const getConnectingClass = () => {
|
||||
if(callState === CallState.CONNECTING
|
||||
|| callState === CallState.INCOMING
|
||||
|| callState === CallState.KEY_EXCHANGE
|
||||
|| callState === CallState.WEB_RTC_EXCHANGE){
|
||||
return classes.connecting;
|
||||
}
|
||||
if(callState === CallState.ACTIVE){
|
||||
return classes.active;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box py={4} style={{
|
||||
cursor: 'pointer'
|
||||
}} px={10} className={getConnectingClass()} onClick={() => setShowCallView(true)}>
|
||||
<Flex align={'center'} justify={'row'} gap={10}>
|
||||
<Flex w={'100%'} justify={'space-between'} align={'center'}>
|
||||
<Flex>
|
||||
{!muted && (
|
||||
<IconMicrophoneOff style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMuted(true);
|
||||
}} size={16} color={'#fff'}></IconMicrophoneOff>
|
||||
)}
|
||||
{muted && (
|
||||
<IconMicrophone style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMuted(false);
|
||||
}} size={16} color={'#fff'}></IconMicrophone>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex justify={'center'} align={'center'} gap={'xs'}>
|
||||
<Text fw={500} c={'#fff'} style={{
|
||||
userSelect: 'none'
|
||||
}} fz={13}>{userInfo?.title || activeCall}</Text>
|
||||
{callState === CallState.CONNECTING && (
|
||||
<Loader type={'dots'} size={12} color="white"></Loader>
|
||||
)}
|
||||
{callState == CallState.ACTIVE && (
|
||||
<Text fw={500} c={'#ffffff'} style={{
|
||||
userSelect: 'none'
|
||||
}} fz={12}>{translateDurationToTime(duration)}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex gap={'xs'} align={'center'} justify={'center'}>
|
||||
{sound && (
|
||||
<IconVolumeOff style={{
|
||||
cursor: 'pointer'
|
||||
}} size={16} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSound(false)
|
||||
}} color={'#fff'}></IconVolumeOff>
|
||||
)}
|
||||
{!sound && (
|
||||
<IconVolume style={{
|
||||
cursor: 'pointer'
|
||||
}} size={16} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSound(true)
|
||||
}} color={'#fff'}></IconVolume>
|
||||
)}
|
||||
<IconPhoneX style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}} size={16} color={'#fff'}></IconPhoneX>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
app/components/Call/Call.tsx
Normal file
139
app/components/Call/Call.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { CallContextValue, CallState } from "@/app/providers/CallProvider/CallProvider";
|
||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { Avatar, Box, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
|
||||
import { IconChevronLeft, IconMicrophone, IconMicrophoneOff, IconPhone, IconPhoneX, IconQrcode, IconVolume, IconVolumeOff, IconX } from "@tabler/icons-react";
|
||||
import { KeyImage } from "../KeyImage/KeyImage";
|
||||
|
||||
export interface CallProps {
|
||||
context: CallContextValue;
|
||||
}
|
||||
|
||||
export function Call(props: CallProps) {
|
||||
const {
|
||||
activeCall,
|
||||
duration,
|
||||
callState,
|
||||
close,
|
||||
sound,
|
||||
setSound,
|
||||
setMuted,
|
||||
setShowCallView,
|
||||
muted,
|
||||
getKeyCast,
|
||||
accept
|
||||
} = props.context;
|
||||
const [userInfo] = useUserInformation(activeCall);
|
||||
const avatars = useAvatars(activeCall);
|
||||
const colors = useRosettaColors();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Box pos={'absolute'} top={0} left={0} w={'100%'} h={'100vh'} style={{
|
||||
zIndex: 11,
|
||||
background: 'linear-gradient(120deg,#141414 0%, #000000 100%)',
|
||||
}}>
|
||||
<Flex h={'100%'} w={'100vw'} direction={'column'} gap={'lg'} pt={'xl'}>
|
||||
<Flex direction={'row'} w={'100%'} gap={'sm'} align={'center'} justify={'space-between'} p={'sm'}>
|
||||
<Flex style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={() => setShowCallView(false)} justify={'center'} align={'center'}>
|
||||
<IconChevronLeft color="white" size={20}></IconChevronLeft>
|
||||
<Text fw={500} c={'white'}>Back</Text>
|
||||
</Flex>
|
||||
<Flex>
|
||||
<Popover width={300} disabled={getKeyCast() == ''} withArrow>
|
||||
<Popover.Target>
|
||||
<IconQrcode color={getKeyCast() == '' ? 'gray' : 'white'} size={24}></IconQrcode>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={'xs'}>
|
||||
<Flex direction={'row'} align={'center'} gap={'xs'}>
|
||||
<Text maw={300} c={'dimmed'} fz={'xs'}>
|
||||
This call is secured by 256 bit end-to-end encryption. Only you and the recipient can read or listen to the content of this call.
|
||||
</Text>
|
||||
<KeyImage radius={0} colors={[
|
||||
theme.colors.blue[1],
|
||||
theme.colors.blue[2],
|
||||
theme.colors.blue[3],
|
||||
theme.colors.blue[4],
|
||||
theme.colors.blue[5]
|
||||
]} size={80} keyRender={getKeyCast()}></KeyImage>
|
||||
</Flex>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex direction={'column'} mt={'xl'} style={{
|
||||
userSelect: 'none'
|
||||
}} w={'100vw'} gap={'sm'} align={'center'} justify={'center'}>
|
||||
<Avatar size={128} bg={avatars.length > 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} color={'initials'} name={userInfo.title}></Avatar>
|
||||
<Text fz={20} fw={'bold'} c={'#FFF'}>{userInfo.title}</Text>
|
||||
{callState == CallState.ACTIVE && (<Text fz={14} c={'#FFF'}>{translateDurationToTime(duration)}</Text>)}
|
||||
{callState == CallState.CONNECTING && (<Text fz={14} c={'#FFF'}>Connecting...</Text>)}
|
||||
{callState == CallState.INCOMING && (<Text fz={14} c={'#FFF'}>Incoming call...</Text>)}
|
||||
{callState == CallState.KEY_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
|
||||
{callState == CallState.WEB_RTC_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
|
||||
<Flex gap={'xl'} align={'center'} justify={'center'} mt={'xl'}>
|
||||
{(callState == CallState.ACTIVE
|
||||
|| callState == CallState.WEB_RTC_EXCHANGE
|
||||
|| callState == CallState.CONNECTING
|
||||
|| callState == CallState.KEY_EXCHANGE) && (
|
||||
<>
|
||||
<Box w={50} onClick={() => setSound(!sound)} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={sound ? colors.chevrons.active : colors.chevrons.disabled}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
{!sound && <IconVolume size={24} color={'#fff'}></IconVolume>}
|
||||
{sound && <IconVolumeOff size={24} color={'#fff'}></IconVolumeOff>}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box w={50} onClick={() => setMuted(!muted)} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={!muted ? colors.chevrons.active : colors.chevrons.disabled}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
{muted && <IconMicrophone size={24} color={'#fff'}></IconMicrophone>}
|
||||
{!muted && <IconMicrophoneOff size={24} color={'#fff'}></IconMicrophoneOff>}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box w={50} onClick={close} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={colors.error}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
<IconPhoneX size={24} color={'#fff'}></IconPhoneX>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{callState == CallState.INCOMING && (
|
||||
<>
|
||||
{userInfo.title != "Rosetta" && (
|
||||
<Box w={50} onClick={close} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={colors.error}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
<IconX size={24} color={'#fff'}></IconX>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
<Box w={userInfo.title != "Rosetta" ? 50 : 100} onClick={accept} style={{
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer'
|
||||
}} h={userInfo.title != "Rosetta" ? 50 : 100} bg={colors.success}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
<IconPhone size={24} color={'#fff'}></IconPhone>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||
import { Avatar, Box, Divider, Flex, Loader, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { Avatar, Box, Divider, Flex, Loader, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconBookmark, IconLockAccess, IconLockCancel, IconTrashX } from "@tabler/icons-react";
|
||||
import { IconBookmark, IconPhone, IconTrashX } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
@@ -20,6 +19,7 @@ import { ReplyHeader } from "../ReplyHeader/ReplyHeader";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { BackToDialogs } from "../BackToDialogs/BackToDialogs";
|
||||
import { useSystemAccounts } from "@/app/providers/SystemAccountsProvider/useSystemAccounts";
|
||||
import { useCalls } from "@/app/providers/CallProvider/useCalls";
|
||||
|
||||
|
||||
export function ChatHeader() {
|
||||
@@ -29,7 +29,6 @@ export function ChatHeader() {
|
||||
const publicKey = usePublicKey();
|
||||
const {deleteMessages, dialog} = useDialog();
|
||||
const theme = useMantineTheme();
|
||||
const [blocked, blockUser, unblockUser] = useBlacklist(dialog);
|
||||
const [opponent, ___, forceUpdateUserInformation] = useUserInformation(dialog);
|
||||
const [protocolState] = useProtocolState();
|
||||
const [userTypeing, setUserTypeing] = useState(false);
|
||||
@@ -39,6 +38,7 @@ export function ChatHeader() {
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const isSystemAccount = systemAccounts.find((acc) => acc.publicKey == dialog) != undefined;
|
||||
const {call} = useCalls();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,20 +78,6 @@ export function ChatHeader() {
|
||||
});
|
||||
}
|
||||
|
||||
const onClickBlockUser = () => {
|
||||
if(opponent.publicKey != "DELETED"
|
||||
&& opponent.publicKey != publicKey){
|
||||
blockUser();
|
||||
}
|
||||
}
|
||||
|
||||
const onClickUnblockUser = () => {
|
||||
if(opponent.publicKey != "DELETED"
|
||||
&& opponent.publicKey != publicKey){
|
||||
unblockUser();
|
||||
}
|
||||
}
|
||||
|
||||
const onClickProfile = () => {
|
||||
if(opponent.publicKey != "DELETED" && opponent.publicKey != publicKey){
|
||||
navigate("/main/profile/" + opponent.publicKey);
|
||||
@@ -116,7 +102,7 @@ export function ChatHeader() {
|
||||
onClick={onClickProfile}
|
||||
>
|
||||
<IconBookmark stroke={2} size={20}></IconBookmark>
|
||||
</Avatar> : <Avatar onClick={onClickProfile} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
|
||||
</Avatar> : <Avatar onClick={onClickProfile} bg={avatars.length > 0 ? '#fff' : undefined} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
|
||||
}
|
||||
<Flex direction={'column'} onClick={onClickProfile}>
|
||||
<Flex align={'center'} gap={3}>
|
||||
@@ -149,32 +135,18 @@ export function ChatHeader() {
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex h={'100%'} align={'center'} gap={'sm'}>
|
||||
<Tooltip onClick={onClickClearMessages} withArrow position={'bottom'} label={"Clear all messages"}>
|
||||
<IconTrashX
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconTrashX>
|
||||
</Tooltip>
|
||||
{publicKey != opponent.publicKey && !blocked && !isSystemAccount && (
|
||||
<Tooltip onClick={onClickBlockUser} withArrow position={'bottom'} label={"Block user"}>
|
||||
<IconLockCancel
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.red[7]} size={24}
|
||||
>
|
||||
</IconLockCancel>
|
||||
</Tooltip>
|
||||
)}
|
||||
{blocked && !isSystemAccount && (
|
||||
<Tooltip onClick={onClickUnblockUser} withArrow position={'bottom'} label={"Unblock user"}>
|
||||
<IconLockAccess
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.green[7]} size={24}
|
||||
>
|
||||
</IconLockAccess>
|
||||
</Tooltip>
|
||||
{publicKey != opponent.publicKey && (
|
||||
<IconPhone
|
||||
onClick={() => call(dialog)}
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconPhone>
|
||||
)}
|
||||
<IconTrashX
|
||||
onClick={onClickClearMessages}
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconTrashX>
|
||||
</Flex>
|
||||
</Flex>}
|
||||
{replyMessages.messages.length > 0 && !replyMessages.inDialogInput && <ReplyHeader></ReplyHeader>}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useDialogInfo } from "@/app/providers/DialogListProvider/useDialogInfo"
|
||||
import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu";
|
||||
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
|
||||
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
|
||||
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||
|
||||
export interface DialogProps extends DialogRow {
|
||||
onClickDialog: (dialog: string) => void;
|
||||
@@ -51,6 +53,7 @@ export function Dialog(props : DialogProps) {
|
||||
|
||||
const isInCurrentDialog = props.dialog_id == сurrentDialogPublicKeyView;
|
||||
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
|
||||
const [protocolState] = useProtocolState();
|
||||
|
||||
usePacket(0x0B, (packet : PacketTyping) => {
|
||||
if(packet.getFromPublicKey() == opponent && packet.getToPublicKey() == publicKey && !fromMe){
|
||||
@@ -85,7 +88,7 @@ export function Dialog(props : DialogProps) {
|
||||
<IconBookmark stroke={2} size={20}></IconBookmark>
|
||||
</Avatar> :
|
||||
<Box style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Avatar src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} />
|
||||
<Avatar bg={avatars.length > 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} />
|
||||
{userInfo.online == OnlineState.ONLINE && (
|
||||
<Box
|
||||
style={{
|
||||
@@ -153,7 +156,7 @@ export function Dialog(props : DialogProps) {
|
||||
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
|
||||
)}
|
||||
{unreaded > 0 && !lastMessageFromMe && <Badge
|
||||
{unreaded > 0 && !lastMessageFromMe && protocolState != ProtocolState.SYNCHRONIZATION && <Badge
|
||||
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
|
||||
c={isInCurrentDialog ? colors.brandColor : 'white'}
|
||||
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}
|
||||
|
||||
@@ -60,6 +60,7 @@ export function DialogInput() {
|
||||
useHotkeys([
|
||||
['Esc', () => {
|
||||
setAttachments([]);
|
||||
deselectAllMessages();
|
||||
}]
|
||||
], [], true);
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { DialogsPanelHeader } from '../DialogsPanelHeader/DialogsPanelHeader';
|
||||
import { useDialogsList } from '@/app/providers/DialogListProvider/useDialogsList';
|
||||
import { useVerifyRequest } from '@/app/providers/DeviceProvider/useVerifyRequest';
|
||||
import { DeviceVerify } from '../DeviceVerify/DeviceVerify';
|
||||
import { ActiveCall } from '../ActiveCall/ActiveCall';
|
||||
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
|
||||
|
||||
export function DialogsPanel() {
|
||||
const [dialogsMode, setDialogsMode] = useState<'all' | 'requests'>('all');
|
||||
@@ -18,6 +20,7 @@ export function DialogsPanel() {
|
||||
const colors = useRosettaColors();
|
||||
const navigate = useNavigate();
|
||||
const device = useVerifyRequest();
|
||||
const [viewState] = useViewPanelsState();
|
||||
|
||||
useEffect(() => {
|
||||
((async () => {
|
||||
@@ -52,6 +55,9 @@ export function DialogsPanel() {
|
||||
direction={'column'}
|
||||
justify={'space-between'}
|
||||
>
|
||||
{viewState == ViewPanelsState.DIALOGS_PANEL_ONLY && (
|
||||
<ActiveCall></ActiveCall>
|
||||
)}
|
||||
<Box>
|
||||
<DialogsPanelHeader></DialogsPanelHeader>
|
||||
{device && (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
left: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
z-index: 15;
|
||||
app-region: no-drag;
|
||||
}
|
||||
.close_btn, .minimize_btn, .maximize_btn {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function MentionRow(props : MentionRowProps) {
|
||||
name={props.title}
|
||||
variant="light"
|
||||
color="initials"
|
||||
bg={avatars.length > 0 ? '#fff' : undefined}
|
||||
src={avatars.length > 0 ? avatars[0].avatar : null}
|
||||
></Avatar>}
|
||||
<Flex direction={'column'}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ErrorBoundaryProvider } from "@/app/providers/ErrorBoundaryProvider/Err
|
||||
import { AttachmentError } from "../AttachmentError/AttachmentError";
|
||||
import { MessageAvatar } from "./MessageAvatar";
|
||||
import { MessageProps } from "../Messages/Message";
|
||||
import { MessageCall } from "./MessageCall";
|
||||
|
||||
export interface MessageAttachmentsProps {
|
||||
attachments: Attachment[];
|
||||
@@ -42,7 +43,6 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
|
||||
text: props.text,
|
||||
parent: props.parent,
|
||||
}
|
||||
console.info("Rendering attachment", attachProps);
|
||||
switch (att.type) {
|
||||
case AttachmentType.MESSAGES:
|
||||
return <MessageReplyMessages {...attachProps} key={index}></MessageReplyMessages>
|
||||
@@ -52,6 +52,8 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
|
||||
return <MessageFile {...attachProps} key={index}></MessageFile>
|
||||
case AttachmentType.AVATAR:
|
||||
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
||||
case AttachmentType.CALL:
|
||||
return <MessageCall {...attachProps} key={index}></MessageCall>
|
||||
default:
|
||||
return <AttachmentError key={index}></AttachmentError>;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ export function MessageAvatar(props: AttachmentProps) {
|
||||
height: 60,
|
||||
width: 60,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover'
|
||||
objectFit: 'cover',
|
||||
background: '#fff'
|
||||
}} src={blob}></img>)}
|
||||
{downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING && preview.length >= 20 && (
|
||||
<>
|
||||
|
||||
62
app/components/MessageAttachments/MessageCall.tsx
Normal file
62
app/components/MessageAttachments/MessageCall.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
|
||||
import { AttachmentProps } from "./MessageAttachments";
|
||||
import { Avatar, Box, Flex, Text } from "@mantine/core";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { IconPhoneOutgoing, IconX } from "@tabler/icons-react";
|
||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||
|
||||
export function MessageCall(props: AttachmentProps) {
|
||||
const {
|
||||
getPreview,
|
||||
} =
|
||||
useAttachment(
|
||||
props.attachment,
|
||||
props.parent,
|
||||
);
|
||||
const preview = getPreview();
|
||||
const callerRole = preview.split("::")[0];
|
||||
const duration = parseInt(preview.split("::")[1]);
|
||||
const colors = useRosettaColors();
|
||||
const error = duration == 0;
|
||||
|
||||
return (
|
||||
<Box p={'sm'} style={{
|
||||
background: colors.mainColor,
|
||||
border: '1px solid ' + colors.borderColor,
|
||||
borderRadius: 8,
|
||||
minWidth: 200,
|
||||
minHeight: 60
|
||||
}}>
|
||||
<Flex gap={'sm'} direction={'row'}>
|
||||
<Avatar bg={error ? colors.error : colors.brandColor} size={40}>
|
||||
{!error && <>
|
||||
{callerRole == "0" && (
|
||||
<IconPhoneOutgoing color={'white'} size={22}></IconPhoneOutgoing>
|
||||
)}
|
||||
{callerRole == "1" && (
|
||||
<IconPhoneOutgoing color={'white'} size={22} style={{
|
||||
transform: 'rotate(180deg)'
|
||||
}}></IconPhoneOutgoing>
|
||||
)}
|
||||
</>}
|
||||
{error && <>
|
||||
<IconX color={'white'} size={22}></IconX>
|
||||
</>}
|
||||
</Avatar>
|
||||
<Flex direction={'column'} gap={5}>
|
||||
<Text size={'sm'}>{
|
||||
error ? (callerRole == "0" ? "Missed call" : "Rejected call") : (callerRole == "0" ? "Incoming call" : "Outgoing call")
|
||||
}</Text>
|
||||
{!error &&
|
||||
<Text size={'xs'} c={colors.chevrons.active}>
|
||||
{translateDurationToTime(duration)}
|
||||
</Text>
|
||||
}
|
||||
{error && <Text size={'xs'} c={colors.error}>
|
||||
Call was not answered or was rejected
|
||||
</Text>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -186,7 +186,7 @@ export function Message(props: MessageProps) {
|
||||
{computedMessageStyle == MessageStyle.ROWS && (
|
||||
<Flex direction={'row'} justify={'space-between'} gap={'sm'}>
|
||||
<Flex direction={'row'} gap={'sm'}>
|
||||
{(!props.avatar_no_render && (md || !props.replyed)) && <Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>}
|
||||
{(!props.avatar_no_render && (md || !props.replyed)) && <Avatar bg={avatars.length > 0 ? '#fff' : undefined} onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>}
|
||||
<Flex direction={'column'}>
|
||||
<Flex direction={'row'} gap={3} align={'center'}>
|
||||
{!props.avatar_no_render && (
|
||||
@@ -213,7 +213,7 @@ export function Message(props: MessageProps) {
|
||||
userSelect: 'text',
|
||||
fontSize: '13px',
|
||||
color: messageStyle == MessageStyle.BUBBLES ? (computedTheme == 'light' ? (props.parent?.from_me ? 'white' : 'black') : 'white') : (computedTheme == 'light' ? 'black' : 'white')
|
||||
}} ml={props.avatar_no_render ? 50 : undefined}>
|
||||
}} ml={props.avatar_no_render ? 50 : undefined} onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<TextParser performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
|
||||
</Box>
|
||||
</Flex>
|
||||
@@ -262,7 +262,7 @@ export function Message(props: MessageProps) {
|
||||
return (
|
||||
<Flex direction={props.from_me ? 'row-reverse' : 'row'} gap={'sm'} align={'flex-end'}>
|
||||
{(md && props.is_last_message_in_stack) && (
|
||||
<Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar>
|
||||
<Avatar bg={avatars.length > 0 ? '#fff' : undefined} onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar>
|
||||
)}
|
||||
{(md && !props.is_last_message_in_stack) && (
|
||||
<Box style={{ width: 40, height: 40, flexShrink: 0 }}></Box>
|
||||
@@ -302,7 +302,7 @@ export function Message(props: MessageProps) {
|
||||
userSelect: 'text',
|
||||
fontSize: '14px',
|
||||
color: props.from_me ? 'white' : (computedTheme == 'light' ? 'black' : 'white')
|
||||
}}>
|
||||
}} onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<TextParser __reserved_2 performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { Button, Flex, Modal, Text } from "@mantine/core";
|
||||
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCornerUpLeft, IconCornerUpRightDouble, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import classes from "./ReplyHeader.module.css";
|
||||
import { DialogsList } from "../DialogsList/DialogsList";
|
||||
@@ -19,10 +19,6 @@ export function ReplyHeader() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const navigate = useNavigate();
|
||||
const {deleteSelectedMessages} = useDialog();
|
||||
|
||||
useHotkeys([
|
||||
['Esc', deselectAllMessages]
|
||||
], [], true);
|
||||
|
||||
const onClickForward = () => {
|
||||
open();
|
||||
|
||||
@@ -40,6 +40,7 @@ export function UserRow(props: UserRowProps) {
|
||||
radius="xl"
|
||||
name={userInfo.title}
|
||||
color={'initials'}
|
||||
bg={avatars.length > 0 ? '#fff' : undefined}
|
||||
src={avatars.length > 0 ? avatars[0].avatar : undefined}
|
||||
/>
|
||||
<Flex direction={'column'}>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.global = window;
|
||||
</script>
|
||||
<script type="module" src="/renderer.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
78
app/hooks/useSound.ts
Normal file
78
app/hooks/useSound.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
export function useSound() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const loopingAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const stopSound = () => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.removeAttribute("src");
|
||||
audioRef.current.load();
|
||||
};
|
||||
|
||||
const playSound = async (sound : string, loop: boolean = false) => {
|
||||
try {
|
||||
if(loop){
|
||||
if (!loopingAudioRef.current) {
|
||||
loopingAudioRef.current = new Audio();
|
||||
loopingAudioRef.current.volume = 0.1;
|
||||
loopingAudioRef.current.preload = "auto";
|
||||
loopingAudioRef.current.loop = true;
|
||||
}
|
||||
|
||||
const url = await window.mediaApi.getSoundUrl(sound);
|
||||
const player = loopingAudioRef.current;
|
||||
|
||||
player.src = url;
|
||||
const playPromise = player.play();
|
||||
if (playPromise) {
|
||||
void playPromise.catch((e) => {
|
||||
console.error("Failed to play looping UI sound:", e);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio();
|
||||
audioRef.current.volume = 0.1;
|
||||
audioRef.current.preload = "auto";
|
||||
audioRef.current.loop = loop;
|
||||
}
|
||||
|
||||
const url = await window.mediaApi.getSoundUrl(sound);
|
||||
const player = audioRef.current;
|
||||
|
||||
stopSound();
|
||||
|
||||
player.src = url;
|
||||
const playPromise = player.play();
|
||||
if (playPromise) {
|
||||
void playPromise.catch((e) => {
|
||||
console.error("Failed to play UI sound:", e);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to prepare UI sound:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const stopLoopSound = () => {
|
||||
if (!loopingAudioRef.current) {
|
||||
return;
|
||||
}
|
||||
loopingAudioRef.current.pause();
|
||||
loopingAudioRef.current.currentTime = 0;
|
||||
loopingAudioRef.current.removeAttribute("src");
|
||||
loopingAudioRef.current.load();
|
||||
}
|
||||
|
||||
return {
|
||||
playSound,
|
||||
stopSound,
|
||||
stopLoopSound
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,19 @@ const useWindow = () => {
|
||||
window.api.send('window-theme', theme);
|
||||
}
|
||||
|
||||
const setWindowPriority = (isTop: boolean) => {
|
||||
if(isTop){
|
||||
window.api.invoke('window-top');
|
||||
} else {
|
||||
window.api.invoke('window-priority-normal');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setSize,
|
||||
setResizeble,
|
||||
setTheme
|
||||
setTheme,
|
||||
setWindowPriority
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants";
|
||||
import { useDialog } from "../DialogProvider/useDialog";
|
||||
import { useCore } from "@/app/hooks/useCore";
|
||||
import { MessageProps } from "@/app/components/Messages/Message";
|
||||
import { useGroups } from "../DialogProvider/useGroups";
|
||||
|
||||
export enum DownloadStatus {
|
||||
DOWNLOADED,
|
||||
@@ -39,6 +40,8 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
||||
const {info} = useConsoleLogger('useAttachment');
|
||||
const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
|
||||
const {getDownloadsPath} = useCore();
|
||||
const {hasGroup} = useGroups();
|
||||
const {dialog} = useDialog();
|
||||
|
||||
const saveAvatar = useSaveAvatar();
|
||||
|
||||
@@ -186,7 +189,11 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
||||
/**
|
||||
* Устанавливаем аватарку тому, кто ее прислал.
|
||||
*/
|
||||
saveAvatar(parentMessage.from, avatarPath, decrypted);
|
||||
let avatarSetTo = parentMessage.from;
|
||||
if(hasGroup(dialog)){
|
||||
avatarSetTo = dialog;
|
||||
}
|
||||
saveAvatar(avatarSetTo, avatarPath, decrypted);
|
||||
return;
|
||||
}
|
||||
/**
|
||||
|
||||
154
app/providers/AttachmentProvider/usePrepareAttachment.ts
Normal file
154
app/providers/AttachmentProvider/usePrepareAttachment.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { encodeWithPassword } from "@/app/workers/crypto/crypto";
|
||||
import { MessageReply } from "../DialogProvider/useReplyMessages";
|
||||
import { Attachment, AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { base64ImageToBlurhash } from "@/app/workers/image/image";
|
||||
import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "@/app/constants";
|
||||
import { useContext, useRef } from "react";
|
||||
import { useTransport } from "../TransportProvider/useTransport";
|
||||
import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
||||
import { useDatabase } from "../DatabaseProvider/useDatabase";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
|
||||
import { DialogContext } from "../DialogProvider/DialogProvider";
|
||||
|
||||
export function usePrepareAttachment() {
|
||||
const intervalsRef = useRef<NodeJS.Timeout>(null);
|
||||
const {uploadFile} = useTransport();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const {runQuery} = useDatabase();
|
||||
const {info} = useConsoleLogger('usePrepareAttachment');
|
||||
const {getDialogCache} = useDialogsCache();
|
||||
const context = useContext(DialogContext);
|
||||
|
||||
const updateTimestampInDialogCache = (dialog : string, message_id: string) => {
|
||||
const dialogCache = getDialogCache(dialog);
|
||||
if(dialogCache == null){
|
||||
return;
|
||||
}
|
||||
for(let i = 0; i < dialogCache.length; i++){
|
||||
if(dialogCache[i].message_id == message_id){
|
||||
dialogCache[i].timestamp = Date.now();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет временную метку в сообщении, пока вложения отправляются,
|
||||
* потому что если этого не делать, то сообщение может быть помечено как
|
||||
* не доставленное из-за таймаута доставки
|
||||
* @param attachments Вложения
|
||||
*/
|
||||
const doTimestampUpdateImMessageWhileAttachmentsSend = (message_id: string, dialog: string) => {
|
||||
if(intervalsRef.current){
|
||||
clearInterval(intervalsRef.current);
|
||||
}
|
||||
intervalsRef.current = setInterval(async () => {
|
||||
/**
|
||||
* Обновляем время в левом меню
|
||||
*/
|
||||
await runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), message_id]);
|
||||
updateDialog(dialog);
|
||||
/**
|
||||
* Обновляем состояние в кэше диалогов
|
||||
*/
|
||||
updateTimestampInDialogCache(dialog, message_id);
|
||||
|
||||
if(context == null || !context){
|
||||
/**
|
||||
* Если этот диалог сейчас не открыт
|
||||
*/
|
||||
return;
|
||||
}
|
||||
context.setMessages((prev) => {
|
||||
return prev.map((value) => {
|
||||
if(value.message_id != message_id){
|
||||
return value;
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
})
|
||||
});
|
||||
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет старый тег если вложения были подготовлены заново
|
||||
* например при пересылке сообщений
|
||||
*/
|
||||
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
|
||||
if(preview.indexOf("::") == -1){
|
||||
return preview;
|
||||
}
|
||||
let parts = preview.split("::");
|
||||
return parts.slice(1).join("::");
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает вложения для отправки. Подготовка
|
||||
* состоит в загрузке файлов на транспортный сервер, мы не делаем
|
||||
* это через WebSocket из-за ограничений по размеру сообщений,
|
||||
* а так же из-за надежности доставки файлов через HTTP
|
||||
* @param attachments Attachments to prepare for sending
|
||||
*/
|
||||
const prepareAttachmentsToSend = async (message_id: string, dialog: string, password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
|
||||
if(attachments.length <= 0){
|
||||
return [];
|
||||
}
|
||||
let prepared : Attachment[] = [];
|
||||
try{
|
||||
for(let i = 0; i < attachments.length; i++){
|
||||
const attachment : Attachment = attachments[i];
|
||||
if(attachment.type == AttachmentType.CALL){
|
||||
/**
|
||||
* Звонок загружать не надо
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
if(attachment.type == AttachmentType.MESSAGES){
|
||||
let reply : MessageReply[] = JSON.parse(attachment.blob)
|
||||
for(let j = 0; j < reply.length; j++){
|
||||
reply[j].attachments = await prepareAttachmentsToSend(message_id, dialog, password, reply[j].attachments, true);
|
||||
}
|
||||
prepared.push({
|
||||
...attachment,
|
||||
blob: await encodeWithPassword(password, JSON.stringify(reply))
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if((attachment.type == AttachmentType.IMAGE
|
||||
|| attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
|
||||
/**
|
||||
* Загружаем превью blurhash для изображения
|
||||
*/
|
||||
const blurhash = await base64ImageToBlurhash(attachment.blob);
|
||||
attachment.preview = blurhash;
|
||||
}
|
||||
doTimestampUpdateImMessageWhileAttachmentsSend(message_id, dialog);
|
||||
const content = await encodeWithPassword(password, attachment.blob);
|
||||
const upid = attachment.id;
|
||||
info(`Uploading attachment with upid: ${upid}`);
|
||||
info(`Attachment content length: ${content.length}`);
|
||||
let tag = await uploadFile(upid, content);
|
||||
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
|
||||
if(intervalsRef.current != null){
|
||||
clearInterval(intervalsRef.current);
|
||||
}
|
||||
prepared.push({
|
||||
...attachment,
|
||||
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
|
||||
blob: ""
|
||||
});
|
||||
}
|
||||
return prepared;
|
||||
}catch(e){
|
||||
return prepared;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prepareAttachmentsToSend
|
||||
}
|
||||
}
|
||||
580
app/providers/CallProvider/CallProvider.tsx
Normal file
580
app/providers/CallProvider/CallProvider.tsx
Normal file
@@ -0,0 +1,580 @@
|
||||
import { Call } from "@/app/components/Call/Call";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { createContext, useEffect, useRef, useState } from "react";
|
||||
import nacl from 'tweetnacl';
|
||||
import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { PacketSignalPeer, SignalType } from "../ProtocolProvider/protocol/packets/packet.signal.peer";
|
||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { PacketWebRTC, WebRTCSignalType } from "../ProtocolProvider/protocol/packets/packet.webrtc";
|
||||
import { PacketIceServers } from "../ProtocolProvider/protocol/packets/packet.ice.servers";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { Button, Flex, Text } from "@mantine/core";
|
||||
import { useSound } from "@/app/hooks/useSound";
|
||||
import useWindow from "@/app/hooks/useWindow";
|
||||
import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE";
|
||||
import { useDeattachedSender } from "../DialogProvider/useDeattachedSender";
|
||||
import { AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { generateRandomKey } from "@/app/utils/utils";
|
||||
|
||||
export interface CallContextValue {
|
||||
call: (callable: string) => void;
|
||||
close: () => void;
|
||||
activeCall: string;
|
||||
callState: CallState;
|
||||
muted: boolean;
|
||||
sound: boolean;
|
||||
setMuted: (muted: boolean) => void;
|
||||
setSound: (sound: boolean) => void;
|
||||
duration: number;
|
||||
setShowCallView: (show: boolean) => void;
|
||||
getKeyCast: () => string;
|
||||
accept: () => void;
|
||||
}
|
||||
|
||||
export enum CallState {
|
||||
CONNECTING,
|
||||
KEY_EXCHANGE,
|
||||
/**
|
||||
* Финальная стадия сигналинга, на которой обе стороны обменялись ключами и теперь устанавливают защищенный канал связи для звонка,
|
||||
* через WebRTC, и готовятся к активному звонку.
|
||||
*/
|
||||
WEB_RTC_EXCHANGE,
|
||||
ACTIVE,
|
||||
ENDED,
|
||||
INCOMING
|
||||
}
|
||||
|
||||
export enum CallRole {
|
||||
/**
|
||||
* Вызывающая сторона, которая инициирует звонок
|
||||
*/
|
||||
CALLER,
|
||||
/**
|
||||
* Принимающая сторона, которая отвечает на звонок и принимает его
|
||||
*/
|
||||
CALLEE
|
||||
}
|
||||
|
||||
export const CallContext = createContext<CallContextValue | null>(null);
|
||||
export interface CallProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CallProvider(props : CallProviderProps) {
|
||||
const [activeCall, setActiveCall] = useState<string>("");
|
||||
const [callState, setCallState] = useState<CallState>(CallState.ENDED);
|
||||
const [muted, setMutedState] = useState<boolean>(false);
|
||||
const [sound, setSoundState] = useState<boolean>(true);
|
||||
const durationIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [showCallView, setShowCallView] = useState<boolean>(callState == CallState.INCOMING);
|
||||
const {info} = useConsoleLogger("CallProvider");
|
||||
const [sessionKeys, setSessionKeys] = useState<nacl.BoxKeyPair | null>(null);
|
||||
const send = useSender();
|
||||
const publicKey = usePublicKey();
|
||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||
const roomIdRef = useRef<string>("");
|
||||
|
||||
const roleRef = useRef<CallRole | null>(null);
|
||||
const sharedSecretRef = useRef<string>("");
|
||||
const iceServersRef = useRef<RTCIceServer[]>([]);
|
||||
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]);
|
||||
const mutedRef = useRef<boolean>(false);
|
||||
const soundRef = useRef<boolean>(true);
|
||||
const {sendMessage} = useDeattachedSender();
|
||||
|
||||
const {playSound, stopSound, stopLoopSound} = useSound();
|
||||
const {setWindowPriority} = useWindow();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if(callState == CallState.ACTIVE){
|
||||
stopLoopSound();
|
||||
stopSound();
|
||||
playSound("connected.mp3");
|
||||
setWindowPriority(false);
|
||||
durationIntervalRef.current = setInterval(() => {
|
||||
setDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
}, [callState]);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Нам нужно получить ICE серверы для установки соединения из разных сетей
|
||||
* Получаем их от сервера
|
||||
*/
|
||||
let packet = new PacketIceServers();
|
||||
send(packet);
|
||||
|
||||
return () => {
|
||||
stopSound();
|
||||
if (remoteAudioRef.current) {
|
||||
remoteAudioRef.current.pause();
|
||||
remoteAudioRef.current.srcObject = null;
|
||||
}
|
||||
peerConnectionRef.current?.close();
|
||||
peerConnectionRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
usePacket(28, async (packet: PacketIceServers) => {
|
||||
let iceServers = packet.getIceServers();
|
||||
/**
|
||||
* ICE серверы получены, теперь нужно привести их к форматку клиента и добавить udp и tcp варианты
|
||||
*/
|
||||
let formattedIceServers: RTCIceServer[] = [];
|
||||
for(let i = 0; i < iceServers.length; i++){
|
||||
let server = iceServers[i];
|
||||
formattedIceServers.push({
|
||||
urls: "turn:" + server.url + "?transport=" + server.transport,
|
||||
username: server.username,
|
||||
credential: server.credential
|
||||
});
|
||||
}
|
||||
iceServersRef.current = formattedIceServers;
|
||||
info("Received ICE servers from server, count: " + formattedIceServers.length);
|
||||
}, []);
|
||||
|
||||
usePacket(27, async (packet: PacketWebRTC) => {
|
||||
if(callState != CallState.WEB_RTC_EXCHANGE && callState != CallState.ACTIVE){
|
||||
/**
|
||||
* Нет активного звонка или мы не на стадии обмена WebRTC сигналами, игнорируем
|
||||
*/
|
||||
return;
|
||||
}
|
||||
const signalType = packet.getSignalType();
|
||||
if(signalType == WebRTCSignalType.ANSWER){
|
||||
/**
|
||||
* Другая сторона (сервер SFU) отправил нам SDP ответ на наш оффер
|
||||
*/
|
||||
const sdp = JSON.parse(packet.getSdpOrCandidate());
|
||||
await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
if(iceCandidatesBufferRef.current.length > 0){
|
||||
/**
|
||||
* У нас есть буферизированные ICE кандидаты, которые мы получили до установки удаленного описания, теперь мы можем их добавить в PeerConnection
|
||||
*/
|
||||
for(let i = 0; i < iceCandidatesBufferRef.current.length; i++){
|
||||
await peerConnectionRef.current?.addIceCandidate(iceCandidatesBufferRef.current[i]);
|
||||
}
|
||||
iceCandidatesBufferRef.current = [];
|
||||
}
|
||||
info("Received WebRTC answer and set remote description");
|
||||
return;
|
||||
}
|
||||
if(signalType == WebRTCSignalType.ICE_CANDIDATE){
|
||||
/**
|
||||
* Другая сторона отправила нам ICE кандидата для установления WebRTC соединения
|
||||
*/
|
||||
const candidate = JSON.parse(packet.getSdpOrCandidate());
|
||||
console.info(candidate);
|
||||
if(peerConnectionRef.current?.remoteDescription == null){
|
||||
/**
|
||||
* Удаленное описание еще не установлено, буферизуем кандидата, чтобы добавить его после установки удаленного описания
|
||||
*/
|
||||
iceCandidatesBufferRef.current.push(new RTCIceCandidate(candidate));
|
||||
info("Received WebRTC ICE candidate but remote description is not set yet, buffering candidate");
|
||||
return;
|
||||
}
|
||||
await peerConnectionRef.current?.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
info("Received WebRTC ICE candidate and added to peer connection");
|
||||
return;
|
||||
}
|
||||
if(signalType == WebRTCSignalType.OFFER && peerConnectionRef.current){
|
||||
/**
|
||||
* SFU сервер отправил нам оффер, например при renegotiation, нам нужно его принять и
|
||||
* отправить ответ (ANSWER)
|
||||
*/
|
||||
const sdp = JSON.parse(packet.getSdpOrCandidate());
|
||||
await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
let answer = await peerConnectionRef.current?.createAnswer();
|
||||
await peerConnectionRef.current?.setLocalDescription(answer);
|
||||
let answerSignal = new PacketWebRTC();
|
||||
answerSignal.setSignalType(WebRTCSignalType.ANSWER);
|
||||
answerSignal.setSdpOrCandidate(JSON.stringify(answer));
|
||||
send(answerSignal);
|
||||
info("Received WebRTC offer, set remote description and sent answer");
|
||||
return;
|
||||
}
|
||||
}, [activeCall, sessionKeys, callState, roomIdRef]);
|
||||
|
||||
usePacket(26, async (packet: PacketSignalPeer) => {
|
||||
const signalType = packet.getSignalType();
|
||||
if(signalType == SignalType.END_CALL_BECAUSE_BUSY) {
|
||||
openCallsModal("Line is busy, the user is currently on another call. Please try again later.");
|
||||
end();
|
||||
}
|
||||
if(signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED) {
|
||||
openCallsModal("The connection with the user was lost. The call has ended.")
|
||||
end();
|
||||
}
|
||||
if(activeCall){
|
||||
/**
|
||||
* У нас уже есть активный звонок, игнорируем все сигналы, кроме сигналов от текущего звонка
|
||||
*/
|
||||
if(packet.getSrc() != activeCall && packet.getSrc() != publicKey){
|
||||
console.info("Received signal from " + packet.getSrc() + " but active call is with " + activeCall + ", ignoring");
|
||||
info("Received signal for another call, ignoring");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(signalType == SignalType.END_CALL){
|
||||
/**
|
||||
* Сбросили звонок
|
||||
*/
|
||||
end();
|
||||
return;
|
||||
}
|
||||
if(signalType == SignalType.CALL){
|
||||
/**
|
||||
* Нам поступает звонок
|
||||
*/
|
||||
setWindowPriority(true);
|
||||
playSound("ringtone.mp3", true);
|
||||
setActiveCall(packet.getSrc());
|
||||
setCallState(CallState.INCOMING);
|
||||
setShowCallView(true);
|
||||
}
|
||||
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLER){
|
||||
console.info("EXCHANGE SIGNAL RECEIVED, CALLER ROLE");
|
||||
/**
|
||||
* Другая сторона сгенерировала ключи для сессии и отправила нам публичную часть,
|
||||
* теперь мы можем создать общую секретную сессию для шифрования звонка
|
||||
*/
|
||||
const sharedPublic = packet.getSharedPublic();
|
||||
if(!sharedPublic){
|
||||
info("Received key exchange signal without shared public key");
|
||||
return;
|
||||
}
|
||||
const sessionKeys = generateSessionKeys();
|
||||
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
||||
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
||||
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
||||
/**
|
||||
* Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию
|
||||
*/
|
||||
const signalPacket = new PacketSignalPeer();
|
||||
signalPacket.setSrc(publicKey);
|
||||
signalPacket.setDst(packet.getSrc());
|
||||
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
|
||||
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
|
||||
send(signalPacket);
|
||||
setCallState(CallState.WEB_RTC_EXCHANGE);
|
||||
/**
|
||||
* Создаем комнату на сервере SFU, комнату создает звонящий
|
||||
*/
|
||||
let webRtcSignal = new PacketSignalPeer();
|
||||
webRtcSignal.setSignalType(SignalType.CREATE_ROOM);
|
||||
webRtcSignal.setSrc(publicKey);
|
||||
webRtcSignal.setDst(packet.getSrc());
|
||||
send(webRtcSignal);
|
||||
}
|
||||
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLEE){
|
||||
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
|
||||
/**
|
||||
* Мы отправили свою публичную часть ключа другой стороне,
|
||||
* теперь мы получили ее публичную часть и можем создать общую
|
||||
* секретную сессию для шифрования звонка
|
||||
*/
|
||||
const sharedPublic = packet.getSharedPublic();
|
||||
if(!sharedPublic){
|
||||
info("Received key exchange signal without shared public key");
|
||||
return;
|
||||
}
|
||||
if(!sessionKeys){
|
||||
info("Received key exchange signal but session keys are not generated");
|
||||
return;
|
||||
}
|
||||
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
||||
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
||||
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
||||
setCallState(CallState.WEB_RTC_EXCHANGE);
|
||||
}
|
||||
if(signalType == SignalType.CREATE_ROOM) {
|
||||
/**
|
||||
* Создана комната для обмена WebRTC потоками
|
||||
*/
|
||||
roomIdRef.current = packet.getRoomId();
|
||||
info("WebRTC room created with id: " + packet.getRoomId());
|
||||
/**
|
||||
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
|
||||
*/
|
||||
peerConnectionRef.current = new RTCPeerConnection({
|
||||
iceServers: iceServersRef.current,
|
||||
// @ts-ignore
|
||||
encodedInsertableStreams: true
|
||||
});
|
||||
/**
|
||||
* Подписываемся на ICE кандидат
|
||||
*/
|
||||
peerConnectionRef.current.onicecandidate = (event) => {
|
||||
if(event.candidate){
|
||||
let candidateSignal = new PacketWebRTC();
|
||||
candidateSignal.setSignalType(WebRTCSignalType.ICE_CANDIDATE);
|
||||
candidateSignal.setSdpOrCandidate(JSON.stringify(event.candidate));
|
||||
send(candidateSignal);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Соединение установлено, можно начинать звонок, переходим в активное состояние звонка
|
||||
*/
|
||||
peerConnectionRef.current.onconnectionstatechange = () => {
|
||||
console.info("Peer connection state changed: " + peerConnectionRef.current?.connectionState);
|
||||
if(peerConnectionRef.current?.connectionState == "connected"){
|
||||
setCallState(CallState.ACTIVE);
|
||||
info("WebRTC connection established, call is active");
|
||||
}
|
||||
}
|
||||
|
||||
peerConnectionRef.current.ontrack = async (event) => {
|
||||
try {
|
||||
await attachReceiverE2EE(event.receiver, Buffer.from(sharedSecretRef.current, "hex"));
|
||||
} catch (e) {
|
||||
console.error("attachReceiverE2EE failed:", e);
|
||||
}
|
||||
/**
|
||||
* При получении медиа-трека с другой стороны
|
||||
*/
|
||||
if(remoteAudioRef.current && event.streams[0]){
|
||||
console.info(event.streams);
|
||||
remoteAudioRef.current.srcObject = event.streams[0];
|
||||
remoteAudioRef.current.muted = !soundRef.current;
|
||||
void remoteAudioRef.current.play().catch((e) => {
|
||||
console.error("Failed to play remote audio:", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запрашиваем Аудио поток с микрофона и добавляем его в PeerConnection, чтобы другая сторона могла его получить и воспроизвести,
|
||||
* когда мы установим WebRTC соединение
|
||||
*/
|
||||
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const audioTrack = localStream.getAudioTracks()[0];
|
||||
|
||||
|
||||
const tx = peerConnectionRef.current.addTransceiver(audioTrack, {
|
||||
direction: "sendrecv",
|
||||
streams: [localStream]
|
||||
});
|
||||
|
||||
await attachSenderE2EE(tx.sender, Buffer.from(sharedSecretRef.current, "hex"));
|
||||
|
||||
/**
|
||||
* Отправляем свой оффер другой стороне
|
||||
*/
|
||||
let offer = await peerConnectionRef.current.createOffer();
|
||||
await peerConnectionRef.current.setLocalDescription(offer);
|
||||
let offerSignal = new PacketWebRTC();
|
||||
offerSignal.setSignalType(WebRTCSignalType.OFFER);
|
||||
offerSignal.setSdpOrCandidate(JSON.stringify(offer));
|
||||
send(offerSignal);
|
||||
return;
|
||||
}
|
||||
}, [activeCall, sessionKeys, duration]);
|
||||
|
||||
const openCallsModal = (text : string) => {
|
||||
modals.open({
|
||||
centered: true,
|
||||
children: (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{text}
|
||||
</Text>
|
||||
<Flex align={'center'} justify={'flex-end'}>
|
||||
<Button color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
|
||||
Close
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
),
|
||||
withCloseButton: false
|
||||
});
|
||||
}
|
||||
|
||||
const generateSessionKeys = () => {
|
||||
const sessionKeys = nacl.box.keyPair();
|
||||
info("Generated keys for call session, len: " + sessionKeys.publicKey.length);
|
||||
setSessionKeys(sessionKeys);
|
||||
return sessionKeys;
|
||||
}
|
||||
|
||||
const call = (dialog: string) => {
|
||||
if(callState == CallState.ACTIVE
|
||||
|| callState == CallState.CONNECTING
|
||||
|| callState == CallState.KEY_EXCHANGE
|
||||
|| callState == CallState.WEB_RTC_EXCHANGE){
|
||||
openCallsModal("You are already on a call, please end the current call before starting a new one.");
|
||||
return;
|
||||
}
|
||||
setWindowPriority(false);
|
||||
setActiveCall(dialog);
|
||||
setCallState(CallState.CONNECTING);
|
||||
setShowCallView(true);
|
||||
const signalPacket = new PacketSignalPeer();
|
||||
signalPacket.setSrc(publicKey);
|
||||
signalPacket.setDst(dialog);
|
||||
signalPacket.setSignalType(SignalType.CALL);
|
||||
send(signalPacket);
|
||||
roleRef.current = CallRole.CALLER;
|
||||
playSound("calling.mp3", true);
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
const packetSignal = new PacketSignalPeer();
|
||||
packetSignal.setSrc(publicKey);
|
||||
packetSignal.setDst(activeCall);
|
||||
packetSignal.setSignalType(SignalType.END_CALL);
|
||||
send(packetSignal);
|
||||
end();
|
||||
}
|
||||
|
||||
const end = () => {
|
||||
stopLoopSound();
|
||||
stopSound();
|
||||
if (remoteAudioRef.current) {
|
||||
remoteAudioRef.current.pause();
|
||||
remoteAudioRef.current.srcObject = null;
|
||||
}
|
||||
generateCallAttachment();
|
||||
setDuration(0);
|
||||
durationIntervalRef.current && clearInterval(durationIntervalRef.current);
|
||||
setWindowPriority(false);
|
||||
playSound("end_call.mp3");
|
||||
peerConnectionRef.current?.close();
|
||||
peerConnectionRef.current = null;
|
||||
roomIdRef.current = "";
|
||||
mutedRef.current = false;
|
||||
soundRef.current = true;
|
||||
setActiveCall("");
|
||||
setCallState(CallState.ENDED);
|
||||
setShowCallView(false);
|
||||
setSessionKeys(null);
|
||||
setDuration(0);
|
||||
setMutedState(false);
|
||||
setSoundState(true);
|
||||
roleRef.current = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет сообщение в диалог с звонящим с информацией о звонке
|
||||
*/
|
||||
const generateCallAttachment = () => {
|
||||
let preview = "";
|
||||
if(roleRef.current == CallRole.CALLER){
|
||||
preview += "1::";
|
||||
}
|
||||
if(roleRef.current == CallRole.CALLEE){
|
||||
preview += "0::";
|
||||
}
|
||||
preview += duration.toString();
|
||||
|
||||
sendMessage(activeCall, "", [{
|
||||
id: generateRandomKey(16),
|
||||
preview: preview,
|
||||
type: AttachmentType.CALL,
|
||||
blob: ""
|
||||
}], false);
|
||||
}
|
||||
|
||||
const accept = () => {
|
||||
if(callState != CallState.INCOMING){
|
||||
/**
|
||||
* Нечего принимать
|
||||
*/
|
||||
return;
|
||||
}
|
||||
setWindowPriority(false);
|
||||
stopLoopSound();
|
||||
stopSound();
|
||||
/**
|
||||
* Звонок принят, генерируем ключи для сессии и отправляем их другой стороне для установления защищенного канала связи
|
||||
*/
|
||||
const keys = generateSessionKeys();
|
||||
const signalPacket = new PacketSignalPeer();
|
||||
signalPacket.setSrc(publicKey);
|
||||
signalPacket.setDst(activeCall);
|
||||
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
|
||||
signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex'));
|
||||
send(signalPacket);
|
||||
setCallState(CallState.KEY_EXCHANGE);
|
||||
roleRef.current = CallRole.CALLEE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает слепок ключа для отображения в UI
|
||||
* чтобы не показывать настоящий ключ
|
||||
* @returns
|
||||
*/
|
||||
const getKeyCast = () => {
|
||||
if(!sharedSecretRef.current){
|
||||
return "";
|
||||
}
|
||||
return sharedSecretRef.current;
|
||||
}
|
||||
|
||||
|
||||
const setMuted = (nextMuted: boolean) => {
|
||||
if (mutedRef.current === nextMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutedRef.current = nextMuted;
|
||||
playSound(nextMuted ? "micro_enable.mp3" : "micro_disable.mp3");
|
||||
|
||||
if(peerConnectionRef.current){
|
||||
peerConnectionRef.current.getSenders().forEach(sender => {
|
||||
if(sender.track?.kind == "audio"){
|
||||
sender.track.enabled = !nextMuted;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setMutedState(nextMuted);
|
||||
}
|
||||
|
||||
const setSound = (nextSound: boolean) => {
|
||||
if (soundRef.current === nextSound) {
|
||||
return;
|
||||
}
|
||||
|
||||
soundRef.current = nextSound;
|
||||
playSound(nextSound ? "sound_enable.mp3" : "sound_disable.mp3");
|
||||
|
||||
if(remoteAudioRef.current){
|
||||
remoteAudioRef.current.muted = !nextSound;
|
||||
if (nextSound) {
|
||||
void remoteAudioRef.current.play().catch((e) => {
|
||||
console.error("Failed to resume remote audio:", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSoundState(nextSound);
|
||||
}
|
||||
|
||||
const context = {
|
||||
call,
|
||||
close,
|
||||
activeCall,
|
||||
callState,
|
||||
muted,
|
||||
sound,
|
||||
setMuted,
|
||||
setSound,
|
||||
duration,
|
||||
setShowCallView,
|
||||
getKeyCast,
|
||||
accept
|
||||
};
|
||||
|
||||
return (
|
||||
<CallContext.Provider value={context}>
|
||||
{props.children}
|
||||
<audio ref={remoteAudioRef} autoPlay playsInline style={{ display: 'none' }} />
|
||||
{showCallView && <Call context={context}></Call>}
|
||||
</CallContext.Provider>
|
||||
)
|
||||
}
|
||||
114
app/providers/CallProvider/audioE2EE.ts
Normal file
114
app/providers/CallProvider/audioE2EE.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import _sodium from "libsodium-wrappers-sumo";
|
||||
|
||||
type KeyInput = Buffer | Uint8Array;
|
||||
|
||||
const senderAttached = new WeakSet<RTCRtpSender>();
|
||||
const receiverAttached = new WeakSet<RTCRtpReceiver>();
|
||||
|
||||
let sodiumReady = false;
|
||||
let sodium: typeof _sodium;
|
||||
|
||||
export async function initE2EE(): Promise<void> {
|
||||
if (sodiumReady) return;
|
||||
await _sodium.ready;
|
||||
sodium = _sodium;
|
||||
sodiumReady = true;
|
||||
}
|
||||
|
||||
function toUint8Array(input: KeyInput): Uint8Array {
|
||||
const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
return new Uint8Array(u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength));
|
||||
}
|
||||
|
||||
function fillNonceFromTimestamp(nonce: Uint8Array, tsRaw: unknown): void {
|
||||
nonce.fill(0);
|
||||
let ts = 0n;
|
||||
if (typeof tsRaw === "bigint") ts = tsRaw;
|
||||
else if (typeof tsRaw === "number" && Number.isFinite(tsRaw)) ts = BigInt(Math.floor(tsRaw));
|
||||
nonce[0] = Number((ts >> 56n) & 0xffn);
|
||||
nonce[1] = Number((ts >> 48n) & 0xffn);
|
||||
nonce[2] = Number((ts >> 40n) & 0xffn);
|
||||
nonce[3] = Number((ts >> 32n) & 0xffn);
|
||||
nonce[4] = Number((ts >> 24n) & 0xffn);
|
||||
nonce[5] = Number((ts >> 16n) & 0xffn);
|
||||
nonce[6] = Number((ts >> 8n) & 0xffn);
|
||||
nonce[7] = Number(ts & 0xffn);
|
||||
}
|
||||
|
||||
function createFrameProcessor(key: Uint8Array) {
|
||||
const nonceLen = sodium.crypto_stream_xchacha20_NONCEBYTES; // 24
|
||||
const nonce = new Uint8Array(nonceLen);
|
||||
|
||||
return function processFrame(data: ArrayBuffer, timestamp: unknown): ArrayBuffer {
|
||||
const input = new Uint8Array(data);
|
||||
fillNonceFromTimestamp(nonce, timestamp);
|
||||
|
||||
const output = sodium.crypto_stream_xchacha20_xor(input, nonce, key);
|
||||
return output.buffer.slice(output.byteOffset, output.byteOffset + output.byteLength) as ArrayBuffer;
|
||||
};
|
||||
}
|
||||
|
||||
function createTransform(processFrame: (data: ArrayBuffer, timestamp: unknown) => ArrayBuffer) {
|
||||
return new TransformStream<any, any>({
|
||||
transform(frame, controller) {
|
||||
try {
|
||||
frame.data = processFrame(frame.data, frame.timestamp);
|
||||
} catch (e) {
|
||||
console.error("[E2EE] frame error:", e);
|
||||
}
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachSenderE2EE(sender: RTCRtpSender, keyInput: KeyInput): Promise<void> {
|
||||
if (senderAttached.has(sender)) return;
|
||||
senderAttached.add(sender);
|
||||
|
||||
await initE2EE();
|
||||
|
||||
const key = toUint8Array(keyInput);
|
||||
const keyLen = sodium.crypto_stream_xchacha20_KEYBYTES; // 32
|
||||
if (key.byteLength < keyLen) {
|
||||
throw new Error(`Key must be at least ${keyLen} bytes`);
|
||||
}
|
||||
|
||||
const anySender = sender as any;
|
||||
if (!anySender.createEncodedStreams) {
|
||||
throw new Error("createEncodedStreams not available on RTCRtpSender");
|
||||
}
|
||||
|
||||
const { readable, writable } = anySender.createEncodedStreams();
|
||||
const processFrame = createFrameProcessor(key.slice(0, keyLen));
|
||||
|
||||
readable
|
||||
.pipeThrough(createTransform(processFrame))
|
||||
.pipeTo(writable)
|
||||
.catch((e) => console.error("[E2EE] Sender pipeline failed:", e));
|
||||
}
|
||||
|
||||
export async function attachReceiverE2EE(receiver: RTCRtpReceiver, keyInput: KeyInput): Promise<void> {
|
||||
if (receiverAttached.has(receiver)) return;
|
||||
receiverAttached.add(receiver);
|
||||
|
||||
await initE2EE();
|
||||
|
||||
const key = toUint8Array(keyInput);
|
||||
const keyLen = sodium.crypto_stream_xchacha20_KEYBYTES; // 32
|
||||
if (key.byteLength < keyLen) {
|
||||
throw new Error(`Key must be at least ${keyLen} bytes`);
|
||||
}
|
||||
|
||||
const anyReceiver = receiver as any;
|
||||
if (!anyReceiver.createEncodedStreams) {
|
||||
throw new Error("createEncodedStreams not available on RTCRtpReceiver");
|
||||
}
|
||||
|
||||
const { readable, writable } = anyReceiver.createEncodedStreams();
|
||||
const processFrame = createFrameProcessor(key.slice(0, keyLen));
|
||||
|
||||
readable
|
||||
.pipeThrough(createTransform(processFrame))
|
||||
.pipeTo(writable)
|
||||
.catch((e) => console.error("[E2EE] Receiver pipeline failed:", e));
|
||||
}
|
||||
5
app/providers/CallProvider/translateDurationTime.ts
Normal file
5
app/providers/CallProvider/translateDurationTime.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const translateDurationToTime = (duration: number) => {
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
|
||||
}
|
||||
15
app/providers/CallProvider/useCalls.ts
Normal file
15
app/providers/CallProvider/useCalls.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useContext } from "react";
|
||||
import { CallContext, CallContextValue } from "./CallProvider";
|
||||
|
||||
/**
|
||||
* Хук предоставляет функции для работы с звонками, такие как инициирование звонка, принятие звонка, завершение звонка и т.д.
|
||||
* Он может использоваться в компонентах, связанных с звонками, для управления состоянием звонков и взаимодействия с сервером.
|
||||
*/
|
||||
export function useCalls() : CallContextValue {
|
||||
const context = useContext(CallContext);
|
||||
if (!context) {
|
||||
throw new Error("useCalls must be used within a CallProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from '@/app/workers/crypto/crypto';
|
||||
import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto';
|
||||
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
|
||||
import { createContext, useEffect, useRef, useState } from 'react';
|
||||
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
|
||||
@@ -11,21 +11,18 @@ import { useBlacklist } from '../BlacklistProvider/useBlacklist';
|
||||
import { useLogger } from '@/app/hooks/useLogger';
|
||||
import { useSender } from '../ProtocolProvider/useSender';
|
||||
import { usePacket } from '../ProtocolProvider/usePacket';
|
||||
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, MESSAGE_MAX_TIME_TO_DELEVERED_S, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
|
||||
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
|
||||
import { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery';
|
||||
import { useIdle } from '@mantine/hooks';
|
||||
import { useWindowFocus } from '@/app/hooks/useWindowFocus';
|
||||
import { useDialogsCache } from './useDialogsCache';
|
||||
import { useConsoleLogger } from '@/app/hooks/useConsoleLogger';
|
||||
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
|
||||
import { MessageReply } from './useReplyMessages';
|
||||
import { useTransport } from '../TransportProvider/useTransport';
|
||||
import { useFileStorage } from '@/app/hooks/useFileStorage';
|
||||
import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
|
||||
import { useDialogsList } from '../DialogListProvider/useDialogsList';
|
||||
import { useGroups } from './useGroups';
|
||||
import { useMentions } from '../DialogStateProvider.tsx/useMentions';
|
||||
import { base64ImageToBlurhash } from '@/app/workers/image/image';
|
||||
|
||||
export interface DialogContextValue {
|
||||
loading: boolean;
|
||||
@@ -33,7 +30,6 @@ export interface DialogContextValue {
|
||||
setMessages: (messages: React.SetStateAction<Message[]>) => void;
|
||||
dialog: string;
|
||||
clearDialogCache: () => void;
|
||||
prepareAttachmentsToSend: (password: string, attachments: Attachment[]) => Promise<Attachment[]>;
|
||||
loadMessagesToTop: () => Promise<void>;
|
||||
loadMessagesToMessageId: (messageId: string) => Promise<void>;
|
||||
}
|
||||
@@ -71,6 +67,23 @@ interface DialogProviderProps {
|
||||
dialog: string;
|
||||
}
|
||||
|
||||
type DialogMessageEvent = {
|
||||
dialogId: string;
|
||||
message: Message;
|
||||
};
|
||||
|
||||
const bus = new EventTarget();
|
||||
|
||||
export const emitDialogMessage = (payload: DialogMessageEvent) => {
|
||||
bus.dispatchEvent(new CustomEvent<DialogMessageEvent>("dialog:message", { detail: payload }));
|
||||
};
|
||||
|
||||
export const onDialogMessage = (handler: (payload: DialogMessageEvent) => void) => {
|
||||
const listener = (e: Event) => handler((e as CustomEvent<DialogMessageEvent>).detail);
|
||||
bus.addEventListener("dialog:message", listener);
|
||||
return () => bus.removeEventListener("dialog:message", listener);
|
||||
};
|
||||
|
||||
export function DialogProvider(props: DialogProviderProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const {allQuery, runQuery} = useDatabase();
|
||||
@@ -88,15 +101,21 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache();
|
||||
const {info, warn, error} = useConsoleLogger('DialogProvider');
|
||||
const [viewState] = useViewPanelsState();
|
||||
const {uploadFile} = useTransport();
|
||||
const {readFile} = useFileStorage();
|
||||
const intervalsRef = useRef<NodeJS.Timeout>(null);
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const {hasGroup, getGroupKey} = useGroups();
|
||||
const {popMention, isMentioned} = useMentions();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onDialogMessage(({ dialogId, message }) => {
|
||||
if (dialogId !== props.dialog) return;
|
||||
setMessages((prev) => [...prev, message]);
|
||||
});
|
||||
return unsub;
|
||||
}, [props.dialog]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentDialogPublicKeyView(props.dialog);
|
||||
return () => {
|
||||
@@ -295,7 +314,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
* Обработчик чтения для личных сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet : PacketRead) => {
|
||||
info("Read packet received in dialog provider");
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
if(fromPublicKey == publicKey){
|
||||
/**
|
||||
@@ -309,7 +327,10 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(fromPublicKey != props.dialog && !idle){
|
||||
if(idle){
|
||||
return;
|
||||
}
|
||||
if(fromPublicKey != props.dialog){
|
||||
return;
|
||||
}
|
||||
setMessages((prev) => prev.map((msg) => {
|
||||
@@ -328,7 +349,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
* Обработчик чтения групповых сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet : PacketRead) => {
|
||||
info("Read packet received in dialog provider");
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
if(fromPublicKey == publicKey){
|
||||
/**
|
||||
@@ -343,7 +363,10 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(toPublicKey != props.dialog && !idle){
|
||||
if(idle){
|
||||
return;
|
||||
}
|
||||
if(toPublicKey != props.dialog){
|
||||
return;
|
||||
}
|
||||
setMessages((prev) => prev.map((msg) => {
|
||||
@@ -388,7 +411,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
}, [publicKey]);
|
||||
|
||||
usePacket(0x08, async (packet : PacketDelivery) => {
|
||||
info("Delivery packet received in dialog provider");
|
||||
const fromPublicKey = packet.getToPublicKey();
|
||||
const messageId = packet.getMessageId();
|
||||
if(fromPublicKey != props.dialog){
|
||||
@@ -420,13 +442,18 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
const timestamp = packet.getTimestamp();
|
||||
const messageId = packet.getMessageId();
|
||||
|
||||
|
||||
if(fromPublicKey != publicKey){
|
||||
/**
|
||||
* Игнорируем если это не сообщение от нас
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(hasGroup(toPublicKey)){
|
||||
/**
|
||||
* Есть другой обработчик для синхронизации групп
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(toPublicKey != props.dialog) {
|
||||
/**
|
||||
* Игнорируем если это не сообщение для этого диалога
|
||||
@@ -466,6 +493,86 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
setMessages((prev) => ([...prev, newMessage]));
|
||||
}, [privatePlain]);
|
||||
|
||||
/**
|
||||
* Обработчик сообщений для синхронизации своих же сообщений в группе
|
||||
*/
|
||||
usePacket(0x06, async (packet: PacketMessage) => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
|
||||
if(fromPublicKey != publicKey){
|
||||
/**
|
||||
* Это не синхронизация, игнорируем ее в этом обработчике
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
if(toPublicKey != props.dialog){
|
||||
/**
|
||||
* Исправление кросс диалогового сообщения
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
if(!hasGroup(props.dialog)){
|
||||
/**
|
||||
* Если это не групповое сообщение, то для него есть
|
||||
* другой обработчик выше
|
||||
*/
|
||||
return;
|
||||
}
|
||||
const content = packet.getContent();
|
||||
const timestamp = packet.getTimestamp();
|
||||
/**
|
||||
* Генерация рандомного ID сообщения по SEED нужна для того,
|
||||
* чтобы сообщение записанное здесь в стек сообщений совпадало
|
||||
* с тем что записывается в БД в файле useDialogFiber.ts
|
||||
*/
|
||||
const messageId = packet.getMessageId();
|
||||
|
||||
const groupKey = await getGroupKey(toPublicKey);
|
||||
if(!groupKey){
|
||||
log("Group key not found for group " + toPublicKey);
|
||||
error("Message dropped because group key not found for group " + toPublicKey);
|
||||
return;
|
||||
}
|
||||
|
||||
let decryptedContent = '';
|
||||
|
||||
try{
|
||||
decryptedContent = await decodeWithPassword(groupKey, content);
|
||||
}catch(e) {
|
||||
decryptedContent = '';
|
||||
}
|
||||
|
||||
let attachments: Attachment[] = [];
|
||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
attachments.push({
|
||||
id: attachment.id,
|
||||
preview: attachment.preview,
|
||||
type: attachment.type,
|
||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
||||
});
|
||||
}
|
||||
|
||||
const newMessage : Message = {
|
||||
from_public_key: fromPublicKey,
|
||||
to_public_key: toPublicKey,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: 0,
|
||||
chacha_key: groupKey,
|
||||
from_me: 1,
|
||||
plain_message: decryptedContent,
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: attachments
|
||||
};
|
||||
|
||||
setMessages((prev) => ([...prev, newMessage]));
|
||||
}, [messages, idle, props.dialog]);
|
||||
|
||||
/**
|
||||
* Обработчик для личных сообщений
|
||||
*/
|
||||
@@ -587,7 +694,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
error("Message dropped because group key not found for group " + toPublicKey);
|
||||
return;
|
||||
}
|
||||
info("New group message packet received from " + fromPublicKey);
|
||||
|
||||
let decryptedContent = '';
|
||||
|
||||
@@ -832,6 +938,16 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if(meta.type == AttachmentType.CALL){
|
||||
/**
|
||||
* Если это звонок
|
||||
*/
|
||||
attachments.push({
|
||||
...meta,
|
||||
blob: ""
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`);
|
||||
if(!fileData) {
|
||||
attachments.push({
|
||||
@@ -853,110 +969,12 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
}
|
||||
return attachments;
|
||||
}catch(e) {
|
||||
console.info(e);
|
||||
error("Failed to parse attachments");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет временную метку в сообщении, пока вложения отправляются,
|
||||
* потому что если этого не делать, то сообщение может быть помечено как
|
||||
* не доставленное из-за таймаута доставки
|
||||
* @param attachments Вложения
|
||||
*/
|
||||
const doTimestampUpdateImMessageWhileAttachmentsSend = (attachments : Attachment[]) => {
|
||||
if(intervalsRef.current){
|
||||
clearInterval(intervalsRef.current);
|
||||
}
|
||||
intervalsRef.current = setInterval(() => {
|
||||
//update timestamp in message to keep message marked as error
|
||||
updateDialog(props.dialog);
|
||||
setMessages((prev) => {
|
||||
return prev.map((value) => {
|
||||
if(value.attachments.length <= 0){
|
||||
return value;
|
||||
}
|
||||
if(value.attachments[0].id != attachments[0].id){
|
||||
return value;
|
||||
}
|
||||
runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), value.message_id]);
|
||||
return {
|
||||
...value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
})
|
||||
});
|
||||
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет старый тег если вложения были подготовлены заново
|
||||
* например при пересылке сообщений
|
||||
*/
|
||||
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
|
||||
if(preview.indexOf("::") == -1){
|
||||
return preview;
|
||||
}
|
||||
let parts = preview.split("::");
|
||||
return parts.slice(1).join("::");
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает вложения для отправки. Подготовка
|
||||
* состоит в загрузке файлов на транспортный сервер, мы не делаем
|
||||
* это через WebSocket из-за ограничений по размеру сообщений,
|
||||
* а так же из-за надежности доставки файлов через HTTP
|
||||
* @param attachments Attachments to prepare for sending
|
||||
*/
|
||||
const prepareAttachmentsToSend = async (password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
|
||||
if(attachments.length <= 0){
|
||||
return [];
|
||||
}
|
||||
let prepared : Attachment[] = [];
|
||||
try{
|
||||
for(let i = 0; i < attachments.length; i++){
|
||||
const attachment : Attachment = attachments[i];
|
||||
if(attachment.type == AttachmentType.MESSAGES){
|
||||
let reply : MessageReply[] = JSON.parse(attachment.blob)
|
||||
for(let j = 0; j < reply.length; j++){
|
||||
reply[j].attachments = await prepareAttachmentsToSend(password, reply[j].attachments, true);
|
||||
}
|
||||
prepared.push({
|
||||
...attachment,
|
||||
blob: await encodeWithPassword(password, JSON.stringify(reply))
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if((attachment.type == AttachmentType.IMAGE
|
||||
|| attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
|
||||
/**
|
||||
* Загружаем превью blurhash для изображения
|
||||
*/
|
||||
const blurhash = await base64ImageToBlurhash(attachment.blob);
|
||||
attachment.preview = blurhash;
|
||||
}
|
||||
doTimestampUpdateImMessageWhileAttachmentsSend(attachments);
|
||||
const content = await encodeWithPassword(password, attachment.blob);
|
||||
const upid = attachment.id;
|
||||
info(`Uploading attachment with upid: ${upid}`);
|
||||
info(`Attachment content length: ${content.length}`);
|
||||
let tag = await uploadFile(upid, content);
|
||||
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
|
||||
if(intervalsRef.current != null){
|
||||
clearInterval(intervalsRef.current);
|
||||
}
|
||||
prepared.push({
|
||||
...attachment,
|
||||
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
|
||||
blob: ""
|
||||
});
|
||||
}
|
||||
return prepared;
|
||||
}catch(e){
|
||||
return prepared;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Дедубликация сообщений по message_id, так как может возникать ситуация, что одно и то же сообщение
|
||||
* может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации
|
||||
@@ -984,7 +1002,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog));
|
||||
},
|
||||
dialog: props.dialog,
|
||||
prepareAttachmentsToSend,
|
||||
loadMessagesToTop,
|
||||
loadMessagesToMessageId
|
||||
}}>
|
||||
|
||||
160
app/providers/DialogProvider/useDeattachedSender.ts
Normal file
160
app/providers/DialogProvider/useDeattachedSender.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { generateRandomKey } from "@/app/utils/utils";
|
||||
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { useGroups } from "./useGroups";
|
||||
import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5 } from "@/app/workers/crypto/crypto";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { AttachmentMeta, DeliveredMessageState, emitDialogMessage, Message } from "./DialogProvider";
|
||||
import { useDatabase } from "../DatabaseProvider/useDatabase";
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||
import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
||||
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
||||
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
|
||||
import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
|
||||
|
||||
/**
|
||||
* Используется для отправки сообщений не внутри DialogProvider, а например в CallProvider,
|
||||
* когда нам нужно отправить сообщение от своего имени что мы совершли звонок (Attachment.CALL)
|
||||
*/
|
||||
export function useDeattachedSender() {
|
||||
const {hasGroup, getGroupKey} = useGroups();
|
||||
const privatePlain = usePrivatePlain();
|
||||
const {warn} = useConsoleLogger('useDeattachedSender');
|
||||
const {runQuery} = useDatabase();
|
||||
const {writeFile} = useFileStorage();
|
||||
const publicKey = usePublicKey();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const [protocolState] = useProtocolState();
|
||||
const privateKey = usePrivateKeyHash();
|
||||
const send = useSender();
|
||||
const {prepareAttachmentsToSend} = usePrepareAttachment();
|
||||
|
||||
/**
|
||||
* Отправка сообщения в диалог
|
||||
* @param dialog ID диалога, может быть как публичным ключом собеседника, так и ID группового диалога
|
||||
* @param message Сообщение
|
||||
* @param attachemnts Вложения
|
||||
*/
|
||||
const sendMessage = async (dialog: string, message: string, attachemnts : Attachment[], serverSent: boolean = false) => {
|
||||
const messageId = generateRandomKey(16);
|
||||
|
||||
|
||||
let cahchaEncrypted = {ciphertext: "", key: "", nonce: ""} as any;
|
||||
let key = Buffer.from("");
|
||||
let encryptedKey = "";
|
||||
let plainMessage = "";
|
||||
let content = "";
|
||||
|
||||
if(!hasGroup(dialog)){
|
||||
cahchaEncrypted = (await chacha20Encrypt(message.trim()) as any);
|
||||
key = Buffer.concat([
|
||||
Buffer.from(cahchaEncrypted.key, "hex"),
|
||||
Buffer.from(cahchaEncrypted.nonce, "hex")]);
|
||||
encryptedKey = await encrypt(key.toString('binary'), dialog);
|
||||
plainMessage = await encodeWithPassword(privatePlain, message.trim());
|
||||
content = cahchaEncrypted.ciphertext;
|
||||
}else{
|
||||
/**
|
||||
* Это группа, там шифрование устроено иначе
|
||||
* для групп используется один общий ключ, который
|
||||
* есть только у участников группы, сам ключ при этом никак
|
||||
* не отправляется по сети (ведь ID у группы общий и у каждого
|
||||
* и так есть этот ключ)
|
||||
*/
|
||||
const groupKey = await getGroupKey(dialog);
|
||||
if(!groupKey){
|
||||
warn("Group key not found for dialog " + dialog);
|
||||
return;
|
||||
}
|
||||
content = await encodeWithPassword(groupKey, message.trim());
|
||||
plainMessage = await encodeWithPassword(privatePlain, message.trim());
|
||||
encryptedKey = ""; // В группах не нужен зашифрованный ключ
|
||||
key = Buffer.from(groupKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Нужно зашифровать ключ еще и нашим ключом,
|
||||
* чтобы в последствии мы могли расшифровать этот ключ у своих
|
||||
* же сообщений (смотреть problem_sync.md)
|
||||
*/
|
||||
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
|
||||
|
||||
emitDialogMessage({
|
||||
dialogId: dialog,
|
||||
message: {
|
||||
from_public_key: publicKey,
|
||||
to_public_key: dialog,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
readed: publicKey == dialog ? 1 : 0,
|
||||
chacha_key: "",
|
||||
from_me: 1,
|
||||
plain_message: message,
|
||||
delivered: serverSent ? (publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: attachemnts
|
||||
} as Message
|
||||
})
|
||||
|
||||
|
||||
let attachmentsMeta : AttachmentMeta[] = [];
|
||||
for(let i = 0; i < attachemnts.length; i++) {
|
||||
const attachment = attachemnts[i];
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview
|
||||
});
|
||||
if(attachment.type == AttachmentType.FILE){
|
||||
/**
|
||||
* Обычно вложения дублируются на диск. Так происходит со всем.
|
||||
* Кроме файлов. Если дублировать файл весом в 2гб на диск отправка будет
|
||||
* занимать очень много времени.
|
||||
* К тому же, это приведет к созданию ненужной копии у отправителя
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`, Buffer.from(await encodeWithPassword(privatePlain, attachment.blob)).toString('binary'));
|
||||
}
|
||||
|
||||
await runQuery(`
|
||||
INSERT INTO messages
|
||||
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
|
||||
(serverSent ? (protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED)
|
||||
), JSON.stringify(attachmentsMeta)]);
|
||||
updateDialog(dialog);
|
||||
if(publicKey == ""
|
||||
|| dialog == ""
|
||||
|| publicKey == dialog) {
|
||||
return;
|
||||
}
|
||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts);
|
||||
if(attachemnts.length <= 0 && message.trim() == ""){
|
||||
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
||||
updateDialog(dialog);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!serverSent){
|
||||
return;
|
||||
}
|
||||
|
||||
const packet = new PacketMessage();
|
||||
packet.setFromPublicKey(publicKey);
|
||||
packet.setToPublicKey(dialog);
|
||||
packet.setContent(content);
|
||||
packet.setChachaKey(encryptedKey);
|
||||
packet.setPrivateKey(privateKey);
|
||||
packet.setMessageId(messageId);
|
||||
packet.setTimestamp(Date.now());
|
||||
packet.setAttachments(preparedToNetworkSendAttachements);
|
||||
packet.setAesChachaKey(aesChachaKey);
|
||||
send(packet);
|
||||
}
|
||||
|
||||
return {sendMessage};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||
import { useGroups } from "./useGroups";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
|
||||
|
||||
export function useDialog() : {
|
||||
messages: Message[];
|
||||
@@ -34,8 +35,7 @@ export function useDialog() : {
|
||||
throw new Error("useDialog must be used within a DialogProvider");
|
||||
}
|
||||
const {loading,
|
||||
messages,
|
||||
prepareAttachmentsToSend,
|
||||
messages,
|
||||
clearDialogCache,
|
||||
setMessages,
|
||||
dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
|
||||
@@ -47,6 +47,7 @@ export function useDialog() : {
|
||||
const [protocolState] = useProtocolState();
|
||||
const {hasGroup, getGroupKey} = useGroups();
|
||||
const {warn} = useConsoleLogger('useDialog');
|
||||
const {prepareAttachmentsToSend} = usePrepareAttachment();
|
||||
|
||||
/**
|
||||
* Отправка сообщения в диалог
|
||||
@@ -146,7 +147,7 @@ export function useDialog() : {
|
||||
|
||||
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
|
||||
console.info("Sending key for message ", key.toString('hex'));
|
||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(key.toString('utf-8'), attachemnts);
|
||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts);
|
||||
if(attachemnts.length <= 0 && message.trim() == ""){
|
||||
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
||||
updateDialog(dialog);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useMentions } from "../DialogStateProvider.tsx/useMentions";
|
||||
import { runTaskInQueue } from "./dialogQueue";
|
||||
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||
import { useUpdateSyncTime } from "./useUpdateSyncTime";
|
||||
|
||||
/**
|
||||
* При вызове будет запущен "фоновый" обработчик
|
||||
@@ -53,27 +54,7 @@ export function useDialogFiber() {
|
||||
const [userInfo] = useUserInformation(publicKey);
|
||||
const { pushMention } = useMentions();
|
||||
const [protocolState] = useProtocolState();
|
||||
|
||||
/**
|
||||
* Обновляет время последней синхронизации для аккаунта
|
||||
* @param timestamp время
|
||||
*/
|
||||
const updateSyncTime = async (timestamp: number) => {
|
||||
if(protocolState == ProtocolState.SYNCHRONIZATION){
|
||||
/**
|
||||
* Если сейчас идет синхронизация то чтобы при синхронизации
|
||||
* не создавать нагрузку на базу данных
|
||||
* по постоянному обновлению, обновляем базу один раз - когда
|
||||
* приходит пакет о том что синхронизация закончилась
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(
|
||||
"INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " +
|
||||
"ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?",
|
||||
[publicKey, timestamp, timestamp, publicKey]
|
||||
);
|
||||
};
|
||||
const updateSyncTime = useUpdateSyncTime();
|
||||
|
||||
/**
|
||||
* Лог
|
||||
@@ -82,101 +63,6 @@ export function useDialogFiber() {
|
||||
info("Starting passive fiber for dialog packets");
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Нам приходят сообщения от себя самих же при синхронизации
|
||||
* нужно обрабатывать их особым образом соотвественно
|
||||
*
|
||||
* Метод нужен для синхронизации своих сообщений
|
||||
*/
|
||||
usePacket(0x06, async (packet: PacketMessage) => {
|
||||
runTaskInQueue(async () => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
const aesChachaKey = packet.getAesChachaKey();
|
||||
const content = packet.getContent();
|
||||
const timestamp = packet.getTimestamp();
|
||||
const messageId = packet.getMessageId();
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это не сообщение от нас
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
const chachaKey = await decodeWithPassword(privatePlain, aesChachaKey);
|
||||
const chachaDecryptedKey = Buffer.from(chachaKey, "binary");
|
||||
const key = chachaDecryptedKey.slice(0, 32);
|
||||
const nonce = chachaDecryptedKey.slice(32);
|
||||
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
||||
await updateSyncTime(timestamp);
|
||||
let attachmentsMeta: any[] = [];
|
||||
let messageAttachments: Attachment[] = [];
|
||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
log("Attachment received id " + attachment.id + " type " + attachment.type);
|
||||
|
||||
let nextLength = messageAttachments.push({
|
||||
...attachment,
|
||||
blob: ""
|
||||
});
|
||||
|
||||
if (attachment.type == AttachmentType.MESSAGES) {
|
||||
/**
|
||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
||||
* в последующем скачивании
|
||||
*/
|
||||
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
|
||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||
}
|
||||
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview
|
||||
});
|
||||
}
|
||||
|
||||
const newMessage: Message = {
|
||||
from_public_key: fromPublicKey,
|
||||
to_public_key: toPublicKey,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: 1, //сообщение прочитано
|
||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
||||
from_me: 1, //сообщение от нас
|
||||
plain_message: (decryptedContent as string),
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: messageAttachments
|
||||
};
|
||||
|
||||
await runQuery(`
|
||||
INSERT INTO messages
|
||||
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [fromPublicKey,
|
||||
toPublicKey,
|
||||
content,
|
||||
timestamp,
|
||||
0, //по умолчанию не прочитаны
|
||||
"sync:" + aesChachaKey,
|
||||
1, //Свои же сообщения всегда от нас
|
||||
await encodeWithPassword(privatePlain, decryptedContent),
|
||||
publicKey,
|
||||
messageId,
|
||||
DeliveredMessageState.DELIVERED,
|
||||
JSON.stringify(attachmentsMeta)]);
|
||||
|
||||
updateDialog(toPublicKey);
|
||||
|
||||
let dialogCache = getDialogCache(toPublicKey);
|
||||
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
|
||||
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [privatePlain, currentDialogPublicKeyView]);
|
||||
|
||||
/**
|
||||
* Обработчик сообщений для группы
|
||||
*/
|
||||
@@ -328,7 +214,7 @@ export function useDialogFiber() {
|
||||
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle]);
|
||||
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle, protocolState]);
|
||||
/**
|
||||
* Обработчик личных сообщений
|
||||
*/
|
||||
@@ -445,9 +331,9 @@ export function useDialogFiber() {
|
||||
* чтобы когда приходит пачка сообщений с сервера в момент того как
|
||||
* пользователь был неактивен, не слать уведомления по всем этим сообщениям
|
||||
*/
|
||||
if (!muted.includes(fromPublicKey) || protocolState != ProtocolState.SYNCHRONIZATION) {
|
||||
if (!muted.includes(fromPublicKey) && protocolState != ProtocolState.SYNCHRONIZATION) {
|
||||
/**
|
||||
* Если пользователь в муте или сейчас идет синхронизация - не отправляем уведомление
|
||||
* Если пользователь в муте И сейчас не идет синхронизация, то не отправляем уведомление
|
||||
*/
|
||||
notify("New message", "You have a new message");
|
||||
}
|
||||
@@ -457,48 +343,7 @@ export function useDialogFiber() {
|
||||
addOrUpdateDialogCache(fromPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle]);
|
||||
|
||||
/**
|
||||
* Обработчик синхронизации прочтения личных сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet: PacketRead) => {
|
||||
runTaskInQueue(async () => {
|
||||
if (hasGroup(packet.getToPublicKey())) {
|
||||
/**
|
||||
* Если это относится к группам, то игнорируем здесь,
|
||||
* для этого есть отдельный слушатель usePacket ниже
|
||||
*/
|
||||
return;
|
||||
}
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это не синхронизация нашего прочтения
|
||||
*/
|
||||
return;
|
||||
}
|
||||
console.info("PACKED_READ_SYNC");
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`,
|
||||
[toPublicKey, fromPublicKey, publicKey]);
|
||||
|
||||
console.info("updating with params ", [fromPublicKey, toPublicKey, publicKey]);
|
||||
updateDialog(toPublicKey);
|
||||
log("Read sync packet from other device");
|
||||
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
|
||||
if (message.from_public_key == toPublicKey && !message.readed) {
|
||||
console.info("Marking message as read in cache for dialog with " + fromPublicKey);
|
||||
console.info({ fromPublicKey, toPublicKey });
|
||||
return {
|
||||
...message,
|
||||
readed: 1
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}));
|
||||
});
|
||||
}, [updateDialog, publicKey]);
|
||||
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle, protocolState]);
|
||||
|
||||
/**
|
||||
* Обработчик прочтения личных сообщений
|
||||
@@ -522,13 +367,11 @@ export function useDialogFiber() {
|
||||
return;
|
||||
}
|
||||
await updateSyncTime(Date.now());
|
||||
console.info("PACKED_READ_IM");
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`, [toPublicKey, fromPublicKey, publicKey]);
|
||||
console.info("read im with params ", [fromPublicKey, toPublicKey, publicKey]);
|
||||
updateDialog(fromPublicKey);
|
||||
log("Read packet received from " + fromPublicKey + " for " + toPublicKey);
|
||||
|
||||
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
|
||||
if (message.from_public_key == toPublicKey && !message.readed) {
|
||||
if (message.from_public_key == publicKey && !message.readed) {
|
||||
console.info("Marking message as read in cache for dialog with " + fromPublicKey);
|
||||
console.info({ fromPublicKey, toPublicKey });
|
||||
return {
|
||||
@@ -540,20 +383,28 @@ export function useDialogFiber() {
|
||||
}));
|
||||
});
|
||||
}, [updateDialog, publicKey]);
|
||||
|
||||
/**
|
||||
* Обработчик прочтения групповых сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet: PacketRead) => {
|
||||
runTaskInQueue(async () => {
|
||||
if (!hasGroup(packet.getToPublicKey())) {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if (!hasGroup(toPublicKey)) {
|
||||
/**
|
||||
* Если это не относится к группам, то игнорируем здесь,
|
||||
* для этого есть отдельный слушатель usePacket выше
|
||||
*/
|
||||
return;
|
||||
}
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if(fromPublicKey == publicKey){
|
||||
/**
|
||||
* Игнорируем если это наше прочтение
|
||||
* которое получается при синхронизации
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND from_public_key = ? AND account = ?`, [toPublicKey, publicKey, publicKey]);
|
||||
await updateSyncTime(Date.now());
|
||||
updateDialog(toPublicKey);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useDialogsCache() {
|
||||
|
||||
const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => {
|
||||
/**
|
||||
* TODO: Optimize this function to avoid full map if possible
|
||||
* TODO: Оптимизировать чтобы проходил снизу вверх
|
||||
*/
|
||||
let newCache = dialogsCache.map((cache) => {
|
||||
let newMessages = cache.messages.map((message) => {
|
||||
|
||||
@@ -162,26 +162,10 @@ export function useGroups() : {
|
||||
const groupId = packet.getGroupId();
|
||||
info(`Creating group with id ${groupId}`);
|
||||
const encryptKey = generateRandomKey(64);
|
||||
const secureKey = await encodeWithPassword(privatePlain, encryptKey);
|
||||
let content = await encodeWithPassword(encryptKey, `$a=Group created`);
|
||||
let plainMessage = await encodeWithPassword(privatePlain, `$a=Group created`);
|
||||
await runQuery(`
|
||||
INSERT INTO groups (account, group_id, title, description, key) VALUES (?, ?, ?, ?, ?)
|
||||
`, [publicKey, groupId, title, description, secureKey]);
|
||||
await runQuery(`
|
||||
INSERT INTO messages
|
||||
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [publicKey, "#group:" + groupId, content, Date.now(), 1, "", 1, plainMessage, publicKey, generateRandomKey(16),
|
||||
DeliveredMessageState.DELIVERED
|
||||
, '[]']);
|
||||
updateDialog("#group:" + groupId);
|
||||
updateGroupInformation({
|
||||
groupId: groupId,
|
||||
title: title,
|
||||
description: description
|
||||
});
|
||||
setLoading(false);
|
||||
navigate(`/main/chat/${prepareForRoute(groupId)}`);
|
||||
/**
|
||||
* После создания группы в нее необходимо зайти, в соотвествии с новым протоколом
|
||||
*/
|
||||
joinGroup(await constructGroupString(groupId, title, encryptKey, description));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,9 +185,11 @@ export function useGroups() : {
|
||||
const groupId = parsed.groupId;
|
||||
const title = parsed.title;
|
||||
const description = parsed.description;
|
||||
const encodedGroupString = await encodeWithPassword(privatePlain, groupString);
|
||||
|
||||
const packet = new PacketGroupJoin();
|
||||
packet.setGroupId(parsed.groupId);
|
||||
packet.setGroupString(encodedGroupString);
|
||||
send(packet);
|
||||
setLoading(true);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useMemory } from "../MemoryProvider/useMemory";
|
||||
import { Attachment } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { DialogContext } from "./DialogProvider";
|
||||
@@ -35,7 +35,6 @@ export function useReplyMessages() {
|
||||
const {dialog} = context;
|
||||
|
||||
const selectMessage = (message : MessageReply) => {
|
||||
console.info(message);
|
||||
if(replyMessages.publicKey != dialog){
|
||||
/**
|
||||
* Сброс выбора сообщений из другого диалога
|
||||
@@ -106,16 +105,6 @@ export function useReplyMessages() {
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if(replyMessages.publicKey != dialog
|
||||
&& replyMessages.inDialogInput != dialog){
|
||||
/**
|
||||
* Сброс выбора сообщений при смене диалога
|
||||
*/
|
||||
deselectAllMessages();
|
||||
}
|
||||
}, [dialog]);
|
||||
|
||||
return {replyMessages,
|
||||
translateMessagesToDialogInput,
|
||||
isSelectionInCurrentDialog,
|
||||
|
||||
@@ -6,20 +6,56 @@ import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { PacketSync, SyncStatus } from "../ProtocolProvider/protocol/packets/packet.sync";
|
||||
import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||
import { whenFinish } from "./dialogQueue";
|
||||
import { runTaskInQueue, whenFinish } from "./dialogQueue";
|
||||
import { useProtocol } from "../ProtocolProvider/useProtocol";
|
||||
import { PacketGroupJoin } from "../ProtocolProvider/protocol/packets/packet.group.join";
|
||||
import { useGroups } from "./useGroups";
|
||||
import { chacha20Decrypt, decodeWithPassword, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { GroupStatus } from "../ProtocolProvider/protocol/packets/packet.group.invite.info";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
||||
import { useUpdateGroupInformation } from "../InformationProvider/useUpdateGroupInformation";
|
||||
import { useGroupInviteStatus } from "./useGroupInviteStatus";
|
||||
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { useUpdateSyncTime } from "./useUpdateSyncTime";
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { DeliveredMessageState, Message } from "./DialogProvider";
|
||||
import { MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from "@/app/constants";
|
||||
import { useMemory } from "../MemoryProvider/useMemory";
|
||||
import { useDialogsCache } from "./useDialogsCache";
|
||||
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
|
||||
import { useLogger } from "@/app/hooks/useLogger";
|
||||
import { useIdle } from "@mantine/hooks";
|
||||
import { useViewPanelsState } from "@/app/hooks/useViewPanelsState";
|
||||
import { useWindowFocus } from "@/app/hooks/useWindowFocus";
|
||||
|
||||
/**
|
||||
* Хук отвечает за синхронизацию сообщений, запрос синхронизации
|
||||
* при подключении
|
||||
*/
|
||||
export function useSynchronize() {
|
||||
const [_, setProtocolState] = useProtocolState();
|
||||
const [protocolState, setProtocolState] = useProtocolState();
|
||||
const {getQuery, runQuery} = useDatabase();
|
||||
const publicKey = usePublicKey();
|
||||
const send = useSender();
|
||||
const {protocol} = useProtocol();
|
||||
const {parseGroupString, hasGroup, getGroupKey} = useGroups();
|
||||
const privatePlain = usePrivatePlain();
|
||||
const {error, info} = useConsoleLogger('useSynchronize');
|
||||
const log = useLogger('useSynchronize');
|
||||
const {setInviteStatusByGroupId} = useGroupInviteStatus('');
|
||||
const updateGroupInformation = useUpdateGroupInformation();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const idle = useIdle(TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD * 1000);
|
||||
const updateSyncTime = useUpdateSyncTime();
|
||||
const {writeFile} = useFileStorage();
|
||||
const { getDialogCache, addOrUpdateDialogCache } = useDialogsCache();
|
||||
const [currentDialogPublicKeyView, __] = useMemory("current-dialog-public-key-view", "", true);
|
||||
const [viewState] = useViewPanelsState();
|
||||
const focused = useWindowFocus();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if(protocol.handshakeExchangeComplete){
|
||||
trySync();
|
||||
@@ -38,15 +74,47 @@ export function useSynchronize() {
|
||||
send(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакет приходит либо при входе в группу (но там используется слушатель once), либо при
|
||||
* синхронизации. В данном случае этот пакет прийдет только при синхронизации
|
||||
*/
|
||||
usePacket(20, async (packet: PacketGroupJoin) => {
|
||||
const decryptedGroupString = await decodeWithPassword(privatePlain, packet.getGroupString());
|
||||
const parsed = await parseGroupString(decryptedGroupString);
|
||||
if(!parsed){
|
||||
error("Received invalid group string, skipping");
|
||||
return;
|
||||
}
|
||||
const groupStatus = packet.getGroupStatus();
|
||||
if(groupStatus != GroupStatus.JOINED){
|
||||
error("Cannot sync group that is not joined, skipping");
|
||||
return;
|
||||
}
|
||||
const secureKey = await encodeWithPassword(privatePlain, parsed.encryptKey);
|
||||
await runQuery(`
|
||||
INSERT INTO groups (account, group_id, title, description, key) VALUES (?, ?, ?, ?, ?)
|
||||
`, [publicKey, parsed.groupId, parsed.title, parsed.description, secureKey]);
|
||||
updateDialog("#group:" + parsed.groupId);
|
||||
setInviteStatusByGroupId(parsed.groupId, GroupStatus.JOINED);
|
||||
updateGroupInformation({
|
||||
groupId: parsed.groupId,
|
||||
title: parsed.title,
|
||||
description: parsed.description
|
||||
});
|
||||
info("Group synchronized " + parsed.groupId);
|
||||
}, [publicKey]);
|
||||
|
||||
usePacket(25, async (packet: PacketSync) => {
|
||||
const status = packet.getStatus();
|
||||
if(status == SyncStatus.BATCH_START){
|
||||
setProtocolState(ProtocolState.SYNCHRONIZATION);
|
||||
}
|
||||
if(status == SyncStatus.BATCH_END){
|
||||
console.info("Batch start");
|
||||
/**
|
||||
* Этот Promise ждет пока все сообщения синхронизируются и обработаются, только
|
||||
* после этого
|
||||
*/
|
||||
await whenFinish();
|
||||
console.info("Batch finished");
|
||||
await runQuery(
|
||||
"INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " +
|
||||
"ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?",
|
||||
@@ -62,4 +130,293 @@ export function useSynchronize() {
|
||||
setProtocolState(ProtocolState.CONNECTED);
|
||||
}
|
||||
}, [publicKey]);
|
||||
|
||||
/**
|
||||
* Нам приходят сообщения от себя самих же при синхронизации
|
||||
* нужно обрабатывать их особым образом соотвественно
|
||||
*
|
||||
* Метод нужен для синхронизации своих сообщений
|
||||
*/
|
||||
usePacket(0x06, async (packet: PacketMessage) => {
|
||||
runTaskInQueue(async () => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
const aesChachaKey = packet.getAesChachaKey();
|
||||
const content = packet.getContent();
|
||||
const timestamp = packet.getTimestamp();
|
||||
const messageId = packet.getMessageId();
|
||||
if(hasGroup(toPublicKey)){
|
||||
/**
|
||||
* Игнорируем если это сообщение для группы, для них есть отдельный слушатель usePacket ниже
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это не сообщение от нас
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
const chachaKey = await decodeWithPassword(privatePlain, aesChachaKey);
|
||||
const chachaDecryptedKey = Buffer.from(chachaKey, "binary");
|
||||
const key = chachaDecryptedKey.slice(0, 32);
|
||||
const nonce = chachaDecryptedKey.slice(32);
|
||||
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
||||
await updateSyncTime(timestamp);
|
||||
let attachmentsMeta: any[] = [];
|
||||
let messageAttachments: Attachment[] = [];
|
||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
|
||||
|
||||
let nextLength = messageAttachments.push({
|
||||
...attachment,
|
||||
blob: ""
|
||||
});
|
||||
|
||||
if (attachment.type == AttachmentType.MESSAGES) {
|
||||
/**
|
||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
||||
* в последующем скачивании
|
||||
*/
|
||||
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
|
||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||
}
|
||||
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview
|
||||
});
|
||||
}
|
||||
|
||||
const newMessage: Message = {
|
||||
from_public_key: fromPublicKey,
|
||||
to_public_key: toPublicKey,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: 1, //сообщение прочитано
|
||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
||||
from_me: 1, //сообщение от нас
|
||||
plain_message: (decryptedContent as string),
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: messageAttachments
|
||||
};
|
||||
|
||||
await runQuery(`
|
||||
INSERT INTO messages
|
||||
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [fromPublicKey,
|
||||
toPublicKey,
|
||||
content,
|
||||
timestamp,
|
||||
0, //по умолчанию не прочитаны
|
||||
"sync:" + aesChachaKey,
|
||||
1, //Свои же сообщения всегда от нас
|
||||
await encodeWithPassword(privatePlain, decryptedContent),
|
||||
publicKey,
|
||||
messageId,
|
||||
DeliveredMessageState.DELIVERED,
|
||||
JSON.stringify(attachmentsMeta)]);
|
||||
|
||||
updateDialog(toPublicKey);
|
||||
|
||||
let dialogCache = getDialogCache(toPublicKey);
|
||||
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
|
||||
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [privatePlain, currentDialogPublicKeyView]);
|
||||
|
||||
/**
|
||||
* Обработчик синхронизации прочтения личных сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet: PacketRead) => {
|
||||
runTaskInQueue(async () => {
|
||||
if (hasGroup(packet.getToPublicKey())) {
|
||||
/**
|
||||
* Если это относится к группам, то игнорируем здесь,
|
||||
* для этого есть отдельный слушатель usePacket ниже
|
||||
*/
|
||||
return;
|
||||
}
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это не синхронизация нашего прочтения
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`,
|
||||
[toPublicKey, fromPublicKey, publicKey]);
|
||||
|
||||
updateDialog(toPublicKey);
|
||||
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
|
||||
if (message.from_public_key == toPublicKey && !message.readed) {
|
||||
console.info({ fromPublicKey, toPublicKey });
|
||||
return {
|
||||
...message,
|
||||
readed: 1
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}));
|
||||
});
|
||||
}, [updateDialog, publicKey]);
|
||||
|
||||
/**
|
||||
* Обработчик синхронизации прочтения групповых сообщений
|
||||
*/
|
||||
usePacket(0x07, async (packet: PacketRead) => {
|
||||
runTaskInQueue(async () => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
if (!hasGroup(toPublicKey)) {
|
||||
/**
|
||||
* Если это не относится к группам, то игнорируем здесь,
|
||||
* для этого есть отдельный слушатель usePacket выше
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if(fromPublicKey != publicKey){
|
||||
/**
|
||||
* Игнорируем если это наше прочтение
|
||||
* которое получается при синхронизации
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND from_public_key != ? AND account = ?`,
|
||||
[toPublicKey, publicKey, publicKey]);
|
||||
await updateSyncTime(Date.now());
|
||||
updateDialog(toPublicKey);
|
||||
addOrUpdateDialogCache(toPublicKey, getDialogCache(toPublicKey).map((message) => {
|
||||
if (!message.readed && message.from_public_key != publicKey) {
|
||||
return {
|
||||
...message,
|
||||
readed: 1
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}));
|
||||
});
|
||||
}, [updateDialog]);
|
||||
|
||||
/**
|
||||
* Обработчик сообщений для синхронизации своих же сообщений в группе
|
||||
*/
|
||||
usePacket(0x06, async (packet: PacketMessage) => {
|
||||
runTaskInQueue(async () => {
|
||||
const fromPublicKey = packet.getFromPublicKey();
|
||||
const toPublicKey = packet.getToPublicKey();
|
||||
const content = packet.getContent();
|
||||
const timestamp = packet.getTimestamp();
|
||||
const messageId = packet.getMessageId();
|
||||
if (!hasGroup(toPublicKey)) {
|
||||
/**
|
||||
* Если это личное сообщение, то игнорируем его здесь
|
||||
* для него есть отдельный слушатель usePacket (снизу)
|
||||
*/
|
||||
return;
|
||||
}
|
||||
if (fromPublicKey != publicKey) {
|
||||
/**
|
||||
* Игнорируем если это сообщения не от нас
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await updateSyncTime(timestamp);
|
||||
const groupKey = await getGroupKey(toPublicKey);
|
||||
if (!groupKey) {
|
||||
log("Group key not found for group " + toPublicKey);
|
||||
error("Message dropped because group key not found for group " + toPublicKey);
|
||||
return;
|
||||
}
|
||||
info("New group message packet received from " + fromPublicKey);
|
||||
|
||||
let decryptedContent = '';
|
||||
|
||||
try {
|
||||
decryptedContent = await decodeWithPassword(groupKey, content);
|
||||
} catch (e) {
|
||||
decryptedContent = '';
|
||||
}
|
||||
|
||||
let attachmentsMeta: any[] = [];
|
||||
let messageAttachments: Attachment[] = [];
|
||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||
const attachment = packet.getAttachments()[i];
|
||||
log("Attachment received id " + attachment.id + " type " + attachment.type);
|
||||
|
||||
let nextLength = messageAttachments.push({
|
||||
...attachment,
|
||||
blob: ""
|
||||
});
|
||||
|
||||
if (attachment.type == AttachmentType.MESSAGES) {
|
||||
/**
|
||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
||||
* в последующем скачивании
|
||||
*/
|
||||
const decryptedBlob = await decodeWithPassword(groupKey, attachment.blob);
|
||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||
}
|
||||
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview
|
||||
});
|
||||
}
|
||||
|
||||
const newMessage: Message = {
|
||||
from_public_key: fromPublicKey,
|
||||
to_public_key: toPublicKey,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
readed: 0,
|
||||
chacha_key: groupKey,
|
||||
from_me: 1,
|
||||
plain_message: decryptedContent,
|
||||
delivered: DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: messageAttachments
|
||||
};
|
||||
|
||||
await runQuery(`
|
||||
INSERT INTO messages
|
||||
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [fromPublicKey,
|
||||
toPublicKey,
|
||||
content,
|
||||
timestamp,
|
||||
0, //по умолчанию не прочитаны
|
||||
"",
|
||||
1, //Свои же сообщения всегда от нас
|
||||
await encodeWithPassword(privatePlain, decryptedContent),
|
||||
publicKey,
|
||||
messageId,
|
||||
DeliveredMessageState.DELIVERED,
|
||||
JSON.stringify(attachmentsMeta)]);
|
||||
|
||||
/**
|
||||
* Так как у нас в toPublicKey приходит ID группы,
|
||||
* то обновляем диалог по этому ID, а не по fromPublicKey
|
||||
* как это сделано в личных сообщениях
|
||||
*/
|
||||
updateDialog(toPublicKey);
|
||||
|
||||
let dialogCache = getDialogCache(toPublicKey);
|
||||
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
|
||||
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
|
||||
}
|
||||
});
|
||||
}, [updateDialog, focused, currentDialogPublicKeyView, viewState, idle, protocolState]);
|
||||
}
|
||||
33
app/providers/DialogProvider/useUpdateSyncTime.ts
Normal file
33
app/providers/DialogProvider/useUpdateSyncTime.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { useDatabase } from "../DatabaseProvider/useDatabase";
|
||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
||||
|
||||
export function useUpdateSyncTime() : (timestamp: number) => Promise<void> {
|
||||
const [protocolState] = useProtocolState();
|
||||
const {runQuery} = useDatabase();
|
||||
const publicKey = usePublicKey();
|
||||
|
||||
/**
|
||||
* Обновляет время последней синхронизации для аккаунта
|
||||
* @param timestamp время
|
||||
*/
|
||||
const updateSyncTime = async (timestamp: number) => {
|
||||
if(protocolState == ProtocolState.SYNCHRONIZATION){
|
||||
/**
|
||||
* Если сейчас идет синхронизация то чтобы при синхронизации
|
||||
* не создавать нагрузку на базу данных
|
||||
* по постоянному обновлению, обновляем базу один раз - когда
|
||||
* приходит пакет о том что синхронизация закончилась
|
||||
*/
|
||||
return;
|
||||
}
|
||||
await runQuery(
|
||||
"INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?) " +
|
||||
"ON CONFLICT(account) DO UPDATE SET last_sync = ? WHERE account = ?",
|
||||
[publicKey, timestamp, timestamp, publicKey]
|
||||
);
|
||||
};
|
||||
|
||||
return updateSyncTime;
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export function ImageViewer(props : ImageViewerProps) {
|
||||
// Wheel zoom (zoom to cursor)
|
||||
const onWheel = (e: React.WheelEvent<HTMLImageElement>) => {
|
||||
//e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
@@ -148,6 +149,7 @@ export function ImageViewer(props : ImageViewerProps) {
|
||||
userSelect: 'none',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
transformOrigin: '0 0',
|
||||
background: '#FFF',
|
||||
transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`,
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
|
||||
@@ -12,6 +12,14 @@ export class PacketGroupJoin extends Packet {
|
||||
|
||||
private groupId: string = "";
|
||||
private groupStatus: GroupStatus = GroupStatus.NOT_JOINED;
|
||||
/**
|
||||
* Строка группы, которая содержит информацию о группе, такую как ее название, описание и ключ
|
||||
* Строка зашифрована обратимым шифрованием, где ключом выступает - реальный приватный ключ
|
||||
* входящего в группу клиента. Нужно это для будущей синхронзации, так как клиенту на его другом
|
||||
* устройстве нужно получить ключ группы и ее информацию. Сервер расшифровать эту строку не может. Эту
|
||||
* строку может расшифровать только клиент, так как она зашифрована его приватным ключом
|
||||
*/
|
||||
private groupString: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x14;
|
||||
@@ -20,6 +28,7 @@ export class PacketGroupJoin extends Packet {
|
||||
public _receive(stream: Stream): void {
|
||||
this.groupId = stream.readString();
|
||||
this.groupStatus = stream.readInt8();
|
||||
this.groupString = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
@@ -27,6 +36,7 @@ export class PacketGroupJoin extends Packet {
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.groupId);
|
||||
stream.writeInt8(this.groupStatus);
|
||||
stream.writeString(this.groupString);
|
||||
return stream;
|
||||
}
|
||||
|
||||
@@ -45,5 +55,13 @@ export class PacketGroupJoin extends Packet {
|
||||
public getGroupStatus(): GroupStatus {
|
||||
return this.groupStatus;
|
||||
}
|
||||
|
||||
public setGroupString(groupString: string) {
|
||||
this.groupString = groupString;
|
||||
}
|
||||
|
||||
public getGroupString(): string {
|
||||
return this.groupString;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export interface G365IceServer {
|
||||
url: string;
|
||||
username: string;
|
||||
credential: string;
|
||||
transport: string;
|
||||
}
|
||||
|
||||
export class PacketIceServers extends Packet {
|
||||
private iceServers: G365IceServer[] = [];
|
||||
|
||||
public getPacketId(): number {
|
||||
return 28;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
const serversCount = stream.readInt16();
|
||||
this.iceServers = [];
|
||||
for(let i = 0; i < serversCount; i++){
|
||||
const url = stream.readString();
|
||||
const username = stream.readString();
|
||||
const credential = stream.readString();
|
||||
const transport = stream.readString();
|
||||
this.iceServers.push({
|
||||
url,
|
||||
username,
|
||||
credential,
|
||||
transport
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeInt16(this.iceServers.length);
|
||||
for(let i = 0; i < this.iceServers.length; i++){
|
||||
const server = this.iceServers[i];
|
||||
stream.writeString(server.url);
|
||||
stream.writeString(server.username || "");
|
||||
stream.writeString(server.credential || "");
|
||||
stream.writeString(server.transport || "");
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getIceServers(): G365IceServer[] {
|
||||
return this.iceServers;
|
||||
}
|
||||
|
||||
public setIceServers(servers: G365IceServer[]) {
|
||||
this.iceServers = servers;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,7 +5,8 @@ export enum AttachmentType {
|
||||
IMAGE = 0,
|
||||
MESSAGES = 1,
|
||||
FILE = 2,
|
||||
AVATAR = 3
|
||||
AVATAR = 3,
|
||||
CALL
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum SignalType {
|
||||
CALL = 0,
|
||||
KEY_EXCHANGE = 1,
|
||||
ACTIVE_CALL = 2,
|
||||
END_CALL = 3,
|
||||
CREATE_ROOM = 4,
|
||||
END_CALL_BECAUSE_PEER_DISCONNECTED = 5,
|
||||
END_CALL_BECAUSE_BUSY = 6
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакет сигналинга, для сигналов WebRTC используется отдельный пакет 27 PacketWebRTCExchange
|
||||
*/
|
||||
export class PacketSignalPeer extends Packet {
|
||||
|
||||
private src: string = "";
|
||||
/**
|
||||
* Назначение
|
||||
*/
|
||||
private dst: string = "";
|
||||
/**
|
||||
* Используется если SignalType == KEY_EXCHANGE, для идентификации сессии обмена ключами
|
||||
*/
|
||||
private sharedPublic: string = "";
|
||||
|
||||
private signalType: SignalType = SignalType.CALL;
|
||||
|
||||
/**
|
||||
* Используется если SignalType == CREATE_ROOM,
|
||||
* для идентификации комнаты на SFU сервере, в которой будет происходить обмен сигналами
|
||||
* WebRTC для установления P2P соединения между участниками звонка
|
||||
*/
|
||||
private roomId: string = "";
|
||||
|
||||
|
||||
public getPacketId(): number {
|
||||
return 26;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.signalType = stream.readInt8();
|
||||
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
|
||||
return;
|
||||
}
|
||||
this.src = stream.readString();
|
||||
this.dst = stream.readString();
|
||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||
this.sharedPublic = stream.readString();
|
||||
}
|
||||
if(this.signalType == SignalType.CREATE_ROOM){
|
||||
this.roomId = stream.readString();
|
||||
}
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeInt8(this.signalType);
|
||||
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
|
||||
return stream;
|
||||
}
|
||||
stream.writeString(this.src);
|
||||
stream.writeString(this.dst);
|
||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||
stream.writeString(this.sharedPublic);
|
||||
}
|
||||
if(this.signalType == SignalType.CREATE_ROOM){
|
||||
stream.writeString(this.roomId);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setDst(dst: string) {
|
||||
this.dst = dst;
|
||||
}
|
||||
|
||||
public setSharedPublic(sharedPublic: string) {
|
||||
this.sharedPublic = sharedPublic;
|
||||
}
|
||||
|
||||
public setSignalType(signalType: SignalType) {
|
||||
this.signalType = signalType;
|
||||
}
|
||||
|
||||
public getDst(): string {
|
||||
return this.dst;
|
||||
}
|
||||
|
||||
public getSharedPublic(): string {
|
||||
return this.sharedPublic;
|
||||
}
|
||||
|
||||
public getSignalType(): SignalType {
|
||||
return this.signalType;
|
||||
}
|
||||
|
||||
public getSrc(): string {
|
||||
return this.src;
|
||||
}
|
||||
|
||||
public setSrc(src: string) {
|
||||
this.src = src;
|
||||
}
|
||||
|
||||
public getRoomId(): string {
|
||||
return this.roomId;
|
||||
}
|
||||
|
||||
public setRoomId(roomId: string) {
|
||||
this.roomId = roomId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum WebRTCSignalType {
|
||||
OFFER = 0,
|
||||
ANSWER = 1,
|
||||
ICE_CANDIDATE = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакет для обмена сигналами WebRTC, такими как оффер, ответ и ICE кандидаты.
|
||||
* Используется на стадии WEB_RTC_EXCHANGE в сигналинге звонков.
|
||||
*/
|
||||
export class PacketWebRTC extends Packet {
|
||||
|
||||
private signalType: WebRTCSignalType = WebRTCSignalType.OFFER;
|
||||
private sdpOrCandidate: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 27;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.signalType = stream.readInt8();
|
||||
this.sdpOrCandidate = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
let stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeInt8(this.signalType);
|
||||
stream.writeString(this.sdpOrCandidate);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setSignalType(type: WebRTCSignalType) {
|
||||
this.signalType = type;
|
||||
}
|
||||
|
||||
public getSignalType(): WebRTCSignalType {
|
||||
return this.signalType;
|
||||
}
|
||||
|
||||
public setSdpOrCandidate(data: string) {
|
||||
this.sdpOrCandidate = data;
|
||||
}
|
||||
|
||||
public getSdpOrCandidate(): string {
|
||||
return this.sdpOrCandidate;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,9 @@ import { PacketDeviceNew } from "./packets/packet.device.new";
|
||||
import { PacketDeviceList } from "./packets/packet.device.list";
|
||||
import { PacketDeviceResolve } from "./packets/packet.device.resolve";
|
||||
import { PacketSync } from "./packets/packet.sync";
|
||||
import { PacketSignalPeer } from "./packets/packet.signal.peer";
|
||||
import { PacketWebRTC } from "./packets/packet.webrtc";
|
||||
import { PacketIceServers } from "./packets/packet.ice.servers";
|
||||
|
||||
export default class Protocol extends EventEmitter {
|
||||
private serverAddress: string;
|
||||
@@ -125,6 +128,9 @@ export default class Protocol extends EventEmitter {
|
||||
this._supportedPackets.set(0x17, new PacketDeviceList());
|
||||
this._supportedPackets.set(0x18, new PacketDeviceResolve());
|
||||
this._supportedPackets.set(25, new PacketSync());
|
||||
this._supportedPackets.set(26, new PacketSignalPeer());
|
||||
this._supportedPackets.set(27, new PacketWebRTC());
|
||||
this._supportedPackets.set(28, new PacketIceServers());
|
||||
}
|
||||
|
||||
private _findWaiters(packetId: number): ((packet: Packet) => void)[] {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export const SERVERS = [
|
||||
//'wss://cdn.rosetta-im.com',
|
||||
//'ws://10.211.55.2:3000',
|
||||
//'ws://127.0.0.1:3000',
|
||||
'wss://wss.rosetta.im'
|
||||
//'ws://192.168.6.82:3000',
|
||||
'wss://wss.rosetta.im'
|
||||
];
|
||||
|
||||
|
||||
export function selectServer(): string {
|
||||
const idx = Math.floor(Math.random() * SERVERS.length);
|
||||
return SERVERS[idx];
|
||||
|
||||
@@ -15,6 +15,8 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
|
||||
return "$a=File";
|
||||
case AttachmentType.AVATAR:
|
||||
return "$a=Avatar";
|
||||
case AttachmentType.CALL:
|
||||
return "$a=Call";
|
||||
default:
|
||||
return "[Unsupported attachment]";
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
export const APP_VERSION = "1.0.5";
|
||||
export const CORE_MIN_REQUIRED_VERSION = "1.5.0";
|
||||
export const APP_VERSION = "1.1.2";
|
||||
export const CORE_MIN_REQUIRED_VERSION = "1.5.3";
|
||||
|
||||
export const RELEASE_NOTICE = `
|
||||
**Обновление v1.0.5** :emoji_1f631:
|
||||
- Оптимизирован код ядра
|
||||
- Исправление ошибки с системой обновления в результате гонки потоков в ядре
|
||||
- Исправление уведомлений при синхронизации
|
||||
- Анимация перемещения диалогов
|
||||
- Оптимизирован код вложений
|
||||
- Исправлен скролл при подгрузке сообщений сверху
|
||||
- Ускорена загрузка диалогов при большом количестве тяжелых изображений
|
||||
**Обновление v1.1.2** :emoji_1f631:
|
||||
- Улучшено шифрование звонков, теперь они более производительне и стабильные.
|
||||
- Добавлены события звонков (начало, окончание, пропущенные).
|
||||
- Улучшена организация кода.
|
||||
- Исправлены мелкие баги и улучшена стабильность приложения.
|
||||
`;
|
||||
@@ -9,12 +9,13 @@ import { useEffect } from "react";
|
||||
import { useViewPanelsState, ViewPanelsState } from "@/app/hooks/useViewPanelsState";
|
||||
import { GroupHeader } from "@/app/components/GroupHeader/GroupHeader";
|
||||
import { useGroups } from "@/app/providers/DialogProvider/useGroups";
|
||||
import { ActiveCall } from "@/app/components/ActiveCall/ActiveCall";
|
||||
|
||||
export function Chat() {
|
||||
const params = useParams();
|
||||
const dialog = params.id || "DELETED";
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const [__, setViewState] = useViewPanelsState();
|
||||
const [viewState, setViewState] = useViewPanelsState();
|
||||
const {hasGroup} = useGroups();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,6 +31,9 @@ export function Chat() {
|
||||
return (<>
|
||||
<DialogProvider dialog={dialog} key={dialog}>
|
||||
<Flex direction={'column'} justify={'space-between'} h={'100%'}>
|
||||
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && (
|
||||
<ActiveCall></ActiveCall>
|
||||
)}
|
||||
{/* Group Header */}
|
||||
{hasGroup(dialog) && <GroupHeader></GroupHeader>}
|
||||
{/* Dialog peer to peer Header */}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useUpdateMessage } from "@/app/hooks/useUpdateMessage";
|
||||
import { useDeviceMessage } from "@/app/hooks/useDeviceMessage";
|
||||
import { UpdateProvider } from "@/app/providers/UpdateProvider/UpdateProvider";
|
||||
import { useSynchronize } from "@/app/providers/DialogProvider/useSynchronize";
|
||||
import { CallProvider } from "@/app/providers/CallProvider/CallProvider";
|
||||
|
||||
export function Main() {
|
||||
const { mainColor, borderColor } = useRosettaColors();
|
||||
@@ -154,52 +155,56 @@ export function Main() {
|
||||
<SystemAccountProvider>
|
||||
<TransportProvider>
|
||||
<UpdateProvider>
|
||||
<Flex direction={'row'} style={{
|
||||
height: '100%',
|
||||
width: '100vw',
|
||||
}}>
|
||||
<div style={{
|
||||
display: viewState != ViewPanelsState.DIALOGS_PANEL_HIDE ? 'block' : 'none',
|
||||
width: viewState == ViewPanelsState.DIALOGS_PANEL_ONLY ? '100%' : '300px',
|
||||
<CallProvider>
|
||||
<Flex direction={'row'} style={{
|
||||
height: '100%',
|
||||
width: '100vw',
|
||||
}}>
|
||||
<DialogsPanel></DialogsPanel>
|
||||
</div>
|
||||
<Divider color={borderColor} orientation={'vertical'}></Divider>
|
||||
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && <Box
|
||||
bg={mainColor}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
height: 'calc(100vh - 27px)',
|
||||
width: `calc(100% - 300px)`,
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path={'/chat/:id'} element={<Chat />}></Route>
|
||||
<Route path={'/profile/:id'} element={<Profile />}></Route>
|
||||
<Route path={'/'} element={<DialogPreview />}></Route>
|
||||
<Route path={'/theme'} element={<Theme />}></Route>
|
||||
<Route path={'/safety'} element={<Safety />}></Route>
|
||||
<Route path={'/update'} element={<Update />}></Route>
|
||||
<Route path={'/backup'} element={<Backup />}></Route>
|
||||
<Route path={'/dialogs'} element={<Dialogs />}></Route>
|
||||
<Route path={'/newgroup'} element={<CreateGroup />}></Route>
|
||||
<Route path={'/group/:id'} element={<GroupInfo />}></Route>
|
||||
<Route path={'/groupencrypt/:key'} element={<GroupEncryption />}></Route>
|
||||
</Routes>
|
||||
</Box>}
|
||||
</Flex>
|
||||
{oldPublicKey && (
|
||||
<Overlay blur={8} color="#333">
|
||||
<Flex direction={'column'} align={'center'} justify={'center'} h={'100%'}>
|
||||
<Alert w={400} variant="filled" color="red" title="Old account">
|
||||
Your account uses an old format public key which is no longer supported. Please create a new account to continue using the application.
|
||||
<br></br>After press "OK" button, the application will close and remove all data.
|
||||
</Alert>
|
||||
<Button w={400} mt={'md'} color="red" onClick={dropAccountsAndMessages}>OK</Button>
|
||||
</Flex>
|
||||
</Overlay>
|
||||
)}
|
||||
<div style={{
|
||||
display: viewState != ViewPanelsState.DIALOGS_PANEL_HIDE ? 'block' : 'none',
|
||||
width: viewState == ViewPanelsState.DIALOGS_PANEL_ONLY ? '100%' : '300px',
|
||||
}}>
|
||||
<DialogsPanel></DialogsPanel>
|
||||
</div>
|
||||
{lg && (
|
||||
<Divider color={borderColor} orientation={'vertical'}></Divider>
|
||||
)}
|
||||
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && <Box
|
||||
bg={mainColor}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
height: 'calc(100vh - 27px)',
|
||||
width: `calc(100% - 300px)`,
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path={'/chat/:id'} element={<Chat />}></Route>
|
||||
<Route path={'/profile/:id'} element={<Profile />}></Route>
|
||||
<Route path={'/'} element={<DialogPreview />}></Route>
|
||||
<Route path={'/theme'} element={<Theme />}></Route>
|
||||
<Route path={'/safety'} element={<Safety />}></Route>
|
||||
<Route path={'/update'} element={<Update />}></Route>
|
||||
<Route path={'/backup'} element={<Backup />}></Route>
|
||||
<Route path={'/dialogs'} element={<Dialogs />}></Route>
|
||||
<Route path={'/newgroup'} element={<CreateGroup />}></Route>
|
||||
<Route path={'/group/:id'} element={<GroupInfo />}></Route>
|
||||
<Route path={'/groupencrypt/:key'} element={<GroupEncryption />}></Route>
|
||||
</Routes>
|
||||
</Box>}
|
||||
</Flex>
|
||||
{oldPublicKey && (
|
||||
<Overlay blur={8} color="#333">
|
||||
<Flex direction={'column'} align={'center'} justify={'center'} h={'100%'}>
|
||||
<Alert w={400} variant="filled" color="red" title="Old account">
|
||||
Your account uses an old format public key which is no longer supported. Please create a new account to continue using the application.
|
||||
<br></br>After press "OK" button, the application will close and remove all data.
|
||||
</Alert>
|
||||
<Button w={400} mt={'md'} color="red" onClick={dropAccountsAndMessages}>OK</Button>
|
||||
</Flex>
|
||||
</Overlay>
|
||||
)}
|
||||
</CallProvider>
|
||||
</UpdateProvider>
|
||||
</TransportProvider>
|
||||
</SystemAccountProvider>
|
||||
|
||||
@@ -21,7 +21,6 @@ self.onmessage = async (event: MessageEvent) => {
|
||||
result = await encrypt(payload.data, payload.publicKey);
|
||||
break;
|
||||
case 'decrypt':
|
||||
console.info("decrypt", payload.privateKey, payload.data);
|
||||
result = await decrypt(payload.data, payload.privateKey);
|
||||
break;
|
||||
case 'chacha20Encrypt':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor } from 'electron'
|
||||
import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor, app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import fs from 'fs'
|
||||
import { WORKING_DIR } from './constants';
|
||||
@@ -45,7 +45,8 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
||||
nodeIntegrationInSubFrames: true,
|
||||
nodeIntegrationInWorker: true,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: true
|
||||
allowRunningInsecureContent: true,
|
||||
autoplayPolicy: 'no-user-gesture-required'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,6 +74,7 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
||||
}
|
||||
|
||||
export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
||||
let bounceId: number | null = null;
|
||||
ipcMain.removeAllListeners('window-resize');
|
||||
ipcMain.removeAllListeners('window-resizeble');
|
||||
ipcMain.removeAllListeners('window-theme');
|
||||
@@ -86,6 +88,38 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
||||
ipcMain.removeHandler('window-minimize');
|
||||
ipcMain.removeHandler('showItemInFolder');
|
||||
ipcMain.removeHandler('openExternal');
|
||||
ipcMain.removeHandler('window-top');
|
||||
ipcMain.removeHandler('window-priority-normal');
|
||||
|
||||
ipcMain.handle('window-top', () => {
|
||||
if (mainWindow.isMinimized()){
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.setAlwaysOnTop(true, "screen-saver"); // самый высокий уровень
|
||||
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
/**
|
||||
* Только в macos! Подпрыгивание иконки в Dock
|
||||
*/
|
||||
bounceId = app.dock!.bounce("critical");
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('window-priority-normal', () => {
|
||||
mainWindow.setAlwaysOnTop(false);
|
||||
mainWindow.setVisibleOnAllWorkspaces(false);
|
||||
if(process.platform === "darwin" && bounceId !== null){
|
||||
/**
|
||||
* Только в macos! Отмена подпрыгивания иконки в Dock
|
||||
*/
|
||||
app.dock!.cancelBounce(bounceId);
|
||||
bounceId = null;
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('open-dev-tools', () => {
|
||||
if (mainWindow.webContents.isDevToolsOpened()) {
|
||||
|
||||
13
lib/main/ipcs/ipcRuntime.ts
Normal file
13
lib/main/ipcs/ipcRuntime.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { app, ipcMain } from "electron";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Получить директорию с ресурсами приложения
|
||||
*/
|
||||
ipcMain.handle('runtime:get-resources', () => {
|
||||
const isDev = !app.isPackaged && process.env['ELECTRON_RENDERER_URL'];
|
||||
if(isDev){
|
||||
return path.join(process.cwd(), "resources")
|
||||
}
|
||||
return path.join(process.resourcesPath, "resources");
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import './ipcs/ipcUpdate'
|
||||
import './ipcs/ipcNotification'
|
||||
import './ipcs/ipcDevice'
|
||||
import './ipcs/ipcCore'
|
||||
import './ipcs/ipcRuntime'
|
||||
import { Tray } from 'electron/main'
|
||||
import { join } from 'path'
|
||||
import { Logger } from './logger'
|
||||
|
||||
3
lib/preload/index.d.ts
vendored
3
lib/preload/index.d.ts
vendored
@@ -13,5 +13,8 @@ declare global {
|
||||
downloadsPath: string;
|
||||
deviceName: string;
|
||||
deviceId: string;
|
||||
mediaApi: {
|
||||
getSoundUrl: (fileName: string) => Promise<string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { contextBridge, ipcRenderer, shell } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import api from './api'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import fs from "node:fs";
|
||||
|
||||
|
||||
async function resolveSound(fileName: string) {
|
||||
const resourcesPath = await ipcRenderer.invoke('runtime:get-resources');
|
||||
const fullPath = path.join(resourcesPath, "sounds", fileName);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`Sound not found: ${fullPath}`);
|
||||
}
|
||||
return pathToFileURL(fullPath).toString();
|
||||
}
|
||||
|
||||
const exposeContext = async () => {
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
@@ -16,6 +28,11 @@ const exposeContext = async () => {
|
||||
ipcRenderer.invoke('ipcCore:showItemInFolder', fullPath);
|
||||
}
|
||||
});
|
||||
contextBridge.exposeInMainWorld("mediaApi", {
|
||||
getSoundUrl: async (fileName: string) => {
|
||||
return resolveSound(fileName);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -23,6 +40,11 @@ const exposeContext = async () => {
|
||||
window.electron = electronAPI
|
||||
window.api = api;
|
||||
window.shell = shell;
|
||||
window.mediaApi = {
|
||||
getSoundUrl: async (fileName: string) => {
|
||||
return resolveSound(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
package.json
22
package.json
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"name": "Rosetta",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.3",
|
||||
"description": "Rosetta Messenger",
|
||||
"main": "./out/main/main.js",
|
||||
"license": "MIT",
|
||||
"build": {
|
||||
"electronUpdaterCompatibility": false,
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "resources/",
|
||||
"to": "resources/"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"node_modules/sqlite3/**/*",
|
||||
"out/main/**/*",
|
||||
@@ -78,9 +84,11 @@
|
||||
"@mantine/form": "^8.3.12",
|
||||
"@mantine/hooks": "^8.3.12",
|
||||
"@mantine/modals": "^8.3.12",
|
||||
"@noble/ciphers": "^1.2.1",
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
"@noble/secp256k1": "^3.0.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/diffie-hellman": "^5.0.3",
|
||||
"@types/elliptic": "^6.4.18",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/npm": "^7.19.3",
|
||||
@@ -90,7 +98,6 @@
|
||||
"bip39": "^3.1.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"elliptic": "^6.6.1",
|
||||
@@ -103,9 +110,13 @@
|
||||
"i": "^0.3.7",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"jszip": "^3.10.1",
|
||||
"libsodium": "^0.8.2",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"libsodium-wrappers-sumo": "^0.8.2",
|
||||
"lottie-react": "^2.4.1",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"npm": "^11.11.0",
|
||||
"pako": "^2.1.0",
|
||||
"react-router-dom": "^7.4.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
@@ -115,6 +126,8 @@
|
||||
"recharts": "^2.15.1",
|
||||
"sql.js": "^1.13.0",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"wa-sqlite": "^1.0.0",
|
||||
"web-bip39": "^0.0.3"
|
||||
},
|
||||
@@ -122,8 +135,10 @@
|
||||
"@electron-toolkit/eslint-config": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@electron/rebuild": "^4.0.3",
|
||||
"@rushstack/eslint-patch": "^1.10.5",
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -132,7 +147,6 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^38.3.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"@electron/rebuild": "^4.0.3",
|
||||
"electron-vite": "^3.0.0",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
|
||||
10
problems/problem_calls.md
Normal file
10
problems/problem_calls.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Проблема обмена ключами в звонках
|
||||
|
||||
Для того, чтобы звонки работали защищенно, необходимо обмениваться ключами между участниками. При этом, сам ключ не должен передаваться по сети, иначе его смогут перехватить злоумышленники. Поэтому, для обмена ключами используется специальный протокол, который позволяет участникам обмениваться ключами без передачи их по сети. В Rosetta можно было использовать уже известный и проверенный контур шифрования по которому шифруются сообщения с использованием публичных и приватных ключей. Однако реализация такого метода может обернуться проблемами при эксплуатации, так как при ответе на звонок, участник может не иметь доступа к приватному ключу, который используется для дешифровки сообщений (например, при ответе на звонок с телефона, когда в приложение не был совершен вход, то есть оно выгружено из памяти), в этом случае звонок не будет работать, так как участник не сможет дешифровать голос и видео из звонка.
|
||||
|
||||
## Возможное решение
|
||||
Можно заставлять пользователя входить в приложение при ответе на звонок, чтобы он мог получить доступ к приватному ключу и дешифровать звонок. Однако, это может привести к неудобствам для пользователей, так как им придется каждый раз входить в приложение при ответе на звонок, что может быть особенно проблематично при использовании мобильного устройства.
|
||||
|
||||
## Решение использованное в Rosetta
|
||||
Для решения проблемы обмена ключами в звонках, в Rosetta используется алгоритм Диффи-Хеллмана для генерации общего секрета между участниками звонка. Этот алгоритм позволяет участникам обмениваться публичными ключами и генерировать общий секрет, который используется для шифрования и дешифрования медиа-потока в звонке. При этом, это не требует входа в приложение, так как ключ генерируется случайный при каждом звонке, и не зависит от приватного ключа. Это обеспечивает удобство для пользователей, так как им не нужно входить в приложение при ответе на звонок, и при этом обеспечивает безопасность звонков, так как ключи не передаются по сети и генерируются случайным образом для каждого звонка. Таким образом, Rosetta обеспечивает безопасные и удобные звонки для пользователей.
|
||||
|
||||
BIN
resources/sounds/calling.mp3
Normal file
BIN
resources/sounds/calling.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/connected.mp3
Normal file
BIN
resources/sounds/connected.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/end_call.mp3
Normal file
BIN
resources/sounds/end_call.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/micro_disable.mp3
Normal file
BIN
resources/sounds/micro_disable.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/micro_enable.mp3
Normal file
BIN
resources/sounds/micro_enable.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/ringtone.mp3
Normal file
BIN
resources/sounds/ringtone.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/sound_disable.mp3
Normal file
BIN
resources/sounds/sound_disable.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/sound_enable.mp3
Normal file
BIN
resources/sounds/sound_enable.mp3
Normal file
Binary file not shown.
@@ -5,6 +5,7 @@
|
||||
"composite": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"lib": ["DOM"],
|
||||
"esModuleInterop": true,
|
||||
"types": ["electron-vite/node"],
|
||||
"paths": {
|
||||
|
||||
Reference in New Issue
Block a user