Compare commits
73 Commits
2e9ccf9c6e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d5980a453 | |||
| 5193ceb071 | |||
| 7434e16252 | |||
| c762e527c2 | |||
| 3291def79b | |||
| 032f92f8a2 | |||
|
|
f4592d03b0 | ||
|
|
6554483939 | ||
|
|
ba12db3c72 | ||
|
|
b596d36543 | ||
|
|
93ef692eb5 | ||
|
|
8fdfe9b786 | ||
|
|
547ac89987 | ||
|
|
130ad9c35a | ||
|
|
adfc6add6f | ||
|
|
cf29cecfd6 | ||
|
|
779c265851 | ||
|
|
8ac952071d | ||
|
|
e1f5cb7eb8 | ||
|
|
30f2c90015 | ||
|
|
a341aedd8d | ||
|
|
a9164c7087 | ||
|
|
04dd23dd5c | ||
|
|
5979c31120 | ||
|
|
c8c85991c7 | ||
|
|
c052fdae41 | ||
|
|
3492a881cc | ||
|
|
febeb58778 | ||
|
|
93e4898bec | ||
|
|
de7a00f37a | ||
|
|
7b3dd6c566 | ||
|
|
70af076248 | ||
|
|
92c9dc03c9 | ||
|
|
7e8d086a74 | ||
|
|
0a0c810105 | ||
|
|
8fbfb4fa5c | ||
|
|
2b9e28ee4a | ||
|
|
d2a506119c | ||
|
|
269f66fdc5 | ||
|
|
5113d18d70 | ||
|
|
cd2dee21ab | ||
|
|
1b14463dbb | ||
|
|
d23ca97be9 | ||
|
|
519aa8802f | ||
|
|
2f2a0b5376 | ||
|
|
61e83bdd43 | ||
|
|
f5bfa153b6 | ||
|
|
81f5e66c56 | ||
|
|
aaa4b4283a | ||
|
|
c9cff515e5 | ||
|
|
94ba139541 | ||
|
|
7e0e97f472 | ||
|
|
8d6090e632 | ||
|
|
fd3fac54f6 | ||
|
|
bd3c0eec69 | ||
|
|
697b797f8c | ||
|
|
429aa614d7 | ||
|
|
e727529b89 | ||
|
|
786d5428f8 | ||
|
|
e4da2510cc | ||
|
|
0e5384b908 | ||
| 013a5d9f17 | |||
|
|
f997581c23 | ||
|
|
1333eb40ce | ||
|
|
57be4631f2 | ||
|
|
7c2718ff9a | ||
|
|
40b2d9c3f0 | ||
|
|
13d52c694f | ||
|
|
27f011ec61 | ||
|
|
7e977b762f | ||
|
|
6dc35d7cca | ||
|
|
a1c8b3d95a | ||
|
|
d2e574d186 |
@@ -51,24 +51,25 @@ jobs:
|
|||||||
- name: NPM offline setup
|
- name: NPM offline setup
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p dist/builds/darwin/arm64 dist/builds/darwin/x64
|
||||||
npm config set cache "$HOME/.npm-cache" --global
|
npm config set cache "$HOME/.npm-cache" --global
|
||||||
npm config set prefer-offline true --global
|
npm config set prefer-offline true --global
|
||||||
|
|
||||||
- name: Install npm dependencies
|
- name: Install npm dependencies
|
||||||
run: npm install --prefer-offline --no-audit --no-fund
|
run: npm install --prefer-offline --no-audit --no-fund
|
||||||
|
|
||||||
- name: Build the application (${{ matrix.arch }})
|
- name: Build the application
|
||||||
run: |
|
run: |
|
||||||
npx electron-vite build
|
npx electron-vite build
|
||||||
npx electron-builder --mac --${{ matrix.arch }}
|
npx electron-builder --mac --${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Check if files exist (${{ matrix.arch }})
|
- name: Check if files exist
|
||||||
run: |
|
run: |
|
||||||
echo "=== Checking dist structure ==="
|
echo "=== Checking dist structure ==="
|
||||||
find dist/builds/darwin/${{ matrix.arch }} -type f -name "*.pkg" 2>/dev/null || echo "No PKG files found"
|
find dist/builds/darwin/${{ matrix.arch }} -type f -name "*.pkg" 2>/dev/null || echo "No PKG files found"
|
||||||
ls -la dist/builds/darwin/${{ matrix.arch }}/ 2>/dev/null || echo "arch folder not found"
|
ls -la dist/builds/darwin/${{ matrix.arch }}/ 2>/dev/null || echo "arch folder not found"
|
||||||
|
|
||||||
- name: Upload ${{ matrix.arch }} to SSH using SCP
|
- name: Upload to SSH using SCP
|
||||||
uses: appleboy/scp-action@master
|
uses: appleboy/scp-action@master
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.SDU_SSH_HOST }}
|
host: ${{ secrets.SDU_SSH_HOST }}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
name: Linux Kernel Build
|
name: Linux Kernel Build
|
||||||
run-name: Build and Upload Linux Kernel
|
run-name: Build and Upload Linux Kernel
|
||||||
|
|
||||||
#Запускаем только кнопкой "Run workflow" в Actions
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
@@ -16,7 +15,12 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [x64, arm64]
|
include:
|
||||||
|
- arch: x64
|
||||||
|
out_dir: x86_64
|
||||||
|
- arch: arm64
|
||||||
|
out_dir: arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -26,55 +30,35 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Cache npm dependencies
|
|
||||||
uses: actions/cache@v5
|
|
||||||
with:
|
|
||||||
path: ~/.npm
|
|
||||||
key: ${{ runner.os }}-linux-npm-${{ hashFiles('**/package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-linux-npm-
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
- name: Cache electron-builder
|
|
||||||
uses: actions/cache@v5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cache/electron
|
|
||||||
key: ${{ runner.os }}-linux-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-linux-electron-builder-
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
- name: NPM offline setup
|
|
||||||
run: |
|
|
||||||
npm config set cache ~/.npm --global
|
|
||||||
npm config set prefer-offline true --global
|
|
||||||
|
|
||||||
- name: Install npm dependencies
|
- name: Install npm dependencies
|
||||||
run: npm install --no-audit --no-fund
|
run: npm install --no-audit --no-fund
|
||||||
|
|
||||||
- name: Build the application (${{ matrix.arch }})
|
- name: Debug ARCH
|
||||||
run: |
|
run: |
|
||||||
|
echo "arch=${{ matrix.arch }}"
|
||||||
|
echo "out_dir=${{ matrix.out_dir }}"
|
||||||
|
|
||||||
|
- name: Build the application
|
||||||
|
run: |
|
||||||
|
mkdir -p dist/builds/linux/x64
|
||||||
|
mkdir -p dist/builds/linux/${{ matrix.out_dir }}
|
||||||
npx electron-vite build
|
npx electron-vite build
|
||||||
npx electron-builder --linux --${{ matrix.arch }}
|
npx electron-builder --linux --${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Check if files exist (${{ matrix.arch }})
|
- name: Check if files exist
|
||||||
run: |
|
run: |
|
||||||
echo "=== Checking dist structure ==="
|
echo "=== Checking dist structure ==="
|
||||||
find dist/builds/linux/${{ matrix.arch }} -type f -name "*.AppImage" 2>/dev/null || echo "No AppImage files found"
|
find dist/builds/linux/${{ matrix.out_dir }} -type f -name "*.AppImage" 2>/dev/null || echo "No AppImage files found"
|
||||||
ls -la dist/builds/linux/${{ matrix.arch }}/ 2>/dev/null || echo "arch folder not found"
|
ls -la dist/builds/linux/${{ matrix.out_dir }}/ 2>/dev/null || echo "arch folder not found"
|
||||||
|
|
||||||
- name: Install SCP in Docker container
|
- name: Upload to SSH using SCP
|
||||||
run: apt-get install -y openssh-client
|
|
||||||
|
|
||||||
- name: Upload ${{ matrix.arch }} to SSH using SCP
|
|
||||||
uses: appleboy/scp-action@master
|
uses: appleboy/scp-action@master
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.SDU_SSH_HOST }}
|
host: ${{ secrets.SDU_SSH_HOST }}
|
||||||
username: ${{ secrets.SDU_SSH_USERNAME }}
|
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||||
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||||
port: 22
|
port: 22
|
||||||
source: "dist/builds/linux/${{ matrix.arch }}/Rosetta-*.AppImage"
|
source: dist/builds/linux/${{ matrix.out_dir }}/Rosetta-*.AppImage
|
||||||
target: "${{ secrets.SDU_SSH_KERNEL }}/linux/${{ matrix.arch }}"
|
target: ${{ secrets.SDU_SSH_KERNEL }}/linux/${{ matrix.arch }}
|
||||||
strip_components: 4
|
strip_components: 4
|
||||||
rm: true
|
rm: true
|
||||||
@@ -13,33 +13,12 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
# Кэш npm (тарифы грузятся из ~/.npm-cache на macOS)
|
- name: Checkout code
|
||||||
- name: Cache npm cache
|
uses: actions/checkout@v6
|
||||||
uses: actions/cache@v5
|
|
||||||
with:
|
|
||||||
path: ${{ env.HOME }}/.npm-cache
|
|
||||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-npm-
|
|
||||||
if-no-files-found: ignore
|
|
||||||
# Кэш для electron-builder (Linux)
|
|
||||||
- name: Cache electron-builder (Linux)
|
|
||||||
uses: actions/cache@v5
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ env.HOME }}/.cache/electron-builder
|
|
||||||
${{ env.HOME }}/.cache/electron
|
|
||||||
key: ${{ runner.os }}-electron-builder-${{ hashFiles('**/electron-builder.yml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-electron-builder-
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
- name: NPM offline setup
|
- name: NPM offline setup
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -88,8 +67,16 @@ jobs:
|
|||||||
find packs -maxdepth 3 -type f 2>/dev/null || true
|
find packs -maxdepth 3 -type f 2>/dev/null || true
|
||||||
test -n "$(find packs -type f -print -quit 2>/dev/null)" || { echo "packs is empty"; exit 1; }
|
test -n "$(find packs -type f -print -quit 2>/dev/null)" || { echo "packs is empty"; exit 1; }
|
||||||
|
|
||||||
- name: Install SCP in Docker container
|
- name: Clean files before upload
|
||||||
run: apt-get install -y openssh-client
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SDU_SSH_HOST }}
|
||||||
|
username: ${{ secrets.SDU_SSH_USERNAME }}
|
||||||
|
password: ${{ secrets.SDU_SSH_PASSWORD }}
|
||||||
|
port: 22
|
||||||
|
script: |
|
||||||
|
mkdir -p "${{ secrets.SDU_SSH_PACKS }}"
|
||||||
|
find "${{ secrets.SDU_SSH_PACKS }}" -mindepth 1 -type f -delete
|
||||||
|
|
||||||
- name: Upload to SSH using SCP
|
- name: Upload to SSH using SCP
|
||||||
uses: appleboy/scp-action@master
|
uses: appleboy/scp-action@master
|
||||||
@@ -101,4 +88,4 @@ jobs:
|
|||||||
source: "packs/*"
|
source: "packs/*"
|
||||||
target: "${{ secrets.SDU_SSH_PACKS }}"
|
target: "${{ secrets.SDU_SSH_PACKS }}"
|
||||||
strip_components: 1
|
strip_components: 1
|
||||||
rm: true
|
rm: false
|
||||||
@@ -22,6 +22,7 @@ import { DialogStateProvider } from './providers/DialogStateProvider.tsx/DialogS
|
|||||||
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
|
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
|
||||||
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
|
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
|
||||||
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
|
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
|
||||||
|
import { PlayerProvider } from './providers/PlayerProvider/PlayerProvider';
|
||||||
window.Buffer = Buffer;
|
window.Buffer = Buffer;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -58,6 +59,7 @@ export default function App() {
|
|||||||
<Topbar></Topbar>
|
<Topbar></Topbar>
|
||||||
<ContextMenuProvider>
|
<ContextMenuProvider>
|
||||||
<ImageViwerProvider>
|
<ImageViwerProvider>
|
||||||
|
<PlayerProvider>
|
||||||
<AvatarProvider>
|
<AvatarProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
@@ -71,6 +73,7 @@ export default function App() {
|
|||||||
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
|
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AvatarProvider>
|
</AvatarProvider>
|
||||||
|
</PlayerProvider>
|
||||||
</ImageViwerProvider>
|
</ImageViwerProvider>
|
||||||
</ContextMenuProvider>
|
</ContextMenuProvider>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function ActionAvatar(props : ActionAvatarProps) {
|
|||||||
size={120}
|
size={120}
|
||||||
radius={120}
|
radius={120}
|
||||||
mx="auto"
|
mx="auto"
|
||||||
bg={'#fff'}
|
bg={avatars.length > 0 ? '#fff' : undefined}
|
||||||
name={props.title.trim() || props.publicKey}
|
name={props.title.trim() || props.publicKey}
|
||||||
color={'initials'}
|
color={'initials'}
|
||||||
src={avatars.length > 0 ?
|
src={avatars.length > 0 ?
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function ChatHeader() {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex h={'100%'} align={'center'} gap={'sm'}>
|
<Flex h={'100%'} align={'center'} gap={'sm'}>
|
||||||
{publicKey != opponent.publicKey && (
|
{publicKey != opponent.publicKey && !isSystemAccount && (
|
||||||
<IconPhone
|
<IconPhone
|
||||||
onClick={() => call(dialog)}
|
onClick={() => call(dialog)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||||
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
|
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
|
||||||
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react";
|
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMicrophone, IconMoodSmile, IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
||||||
import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
|
import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
|
||||||
@@ -25,7 +25,8 @@ import { AnimatedButton } from "../AnimatedButton/AnimatedButton";
|
|||||||
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
|
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
|
||||||
import { MentionList, Mention } from "../MentionList/MentionList";
|
import { MentionList, Mention } from "../MentionList/MentionList";
|
||||||
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
|
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
|
||||||
|
import { useVoiceMessage } from "./useVoiceMessage";
|
||||||
|
import { VoiceRecorder } from "../VoiceRecorder/VoiceRecorder";
|
||||||
|
|
||||||
export function DialogInput() {
|
export function DialogInput() {
|
||||||
const colors = useRosettaColors();
|
const colors = useRosettaColors();
|
||||||
@@ -47,6 +48,7 @@ export function DialogInput() {
|
|||||||
const [mentionList, setMentionList] = useState<Mention[]>([]);
|
const [mentionList, setMentionList] = useState<Mention[]>([]);
|
||||||
const mentionHandling = useRef<string>("");
|
const mentionHandling = useRef<string>("");
|
||||||
const {getDraft, saveDraft} = useDrafts(dialog);
|
const {getDraft, saveDraft} = useDrafts(dialog);
|
||||||
|
const {start, stop, isRecording, duration, waves, getAudioBlob, interpolateCompressWaves} = useVoiceMessage();
|
||||||
|
|
||||||
|
|
||||||
const avatars = useAvatars(
|
const avatars = useAvatars(
|
||||||
@@ -64,6 +66,15 @@ export function DialogInput() {
|
|||||||
}]
|
}]
|
||||||
], [], true);
|
], [], true);
|
||||||
|
|
||||||
|
const hasText = message.trim().length > 0;
|
||||||
|
const showSendIcon = hasText || attachments.length > 0 || isRecording;
|
||||||
|
|
||||||
|
const onMicroClick = () => {
|
||||||
|
if(!isRecording) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fileDialog = useFileDialog({
|
const fileDialog = useFileDialog({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
//naccept: '*',
|
//naccept: '*',
|
||||||
@@ -89,7 +100,11 @@ export function DialogInput() {
|
|||||||
blob: fileContent,
|
blob: fileContent,
|
||||||
id: generateRandomKey(8),
|
id: generateRandomKey(8),
|
||||||
type: AttachmentType.FILE,
|
type: AttachmentType.FILE,
|
||||||
preview: files[0].size + "::" + files[0].name
|
preview: files[0].size + "::" + files[0].name,
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -116,7 +131,11 @@ export function DialogInput() {
|
|||||||
type: AttachmentType.MESSAGES,
|
type: AttachmentType.MESSAGES,
|
||||||
id: generateRandomKey(8),
|
id: generateRandomKey(8),
|
||||||
blob: JSON.stringify([...replyMessages.messages]),
|
blob: JSON.stringify([...replyMessages.messages]),
|
||||||
preview: ""
|
preview: "",
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
}]);
|
}]);
|
||||||
if(editableDivRef.current){
|
if(editableDivRef.current){
|
||||||
editableDivRef.current.focus();
|
editableDivRef.current.focus();
|
||||||
@@ -180,8 +199,28 @@ export function DialogInput() {
|
|||||||
mentionHandling.current = username;
|
mentionHandling.current = username;
|
||||||
}
|
}
|
||||||
|
|
||||||
const send = () => {
|
const send = async () => {
|
||||||
if(blocked || (message.trim() == "" && attachments.length <= 0)) {
|
if(blocked || (message.trim() == "" && attachments.length <= 0 && !isRecording)){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(isRecording){
|
||||||
|
const audioBlob = getAudioBlob();
|
||||||
|
stop();
|
||||||
|
if(!audioBlob){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendMessage("", [
|
||||||
|
{
|
||||||
|
blob: Buffer.from(await audioBlob.arrayBuffer()).toString('hex'),
|
||||||
|
id: generateRandomKey(8),
|
||||||
|
type: AttachmentType.VOICE,
|
||||||
|
preview: duration + "::" + interpolateCompressWaves(35).join(","),
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendMessage(message, attachments);
|
sendMessage(message, attachments);
|
||||||
@@ -230,7 +269,11 @@ export function DialogInput() {
|
|||||||
blob: avatars[0].avatar,
|
blob: avatars[0].avatar,
|
||||||
id: generateRandomKey(8),
|
id: generateRandomKey(8),
|
||||||
type: AttachmentType.AVATAR,
|
type: AttachmentType.AVATAR,
|
||||||
preview: ""
|
preview: "",
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
}]);
|
}]);
|
||||||
if(editableDivRef.current){
|
if(editableDivRef.current){
|
||||||
editableDivRef.current.focus();
|
editableDivRef.current.focus();
|
||||||
@@ -270,7 +313,11 @@ export function DialogInput() {
|
|||||||
blob: base64Image,
|
blob: base64Image,
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
type: AttachmentType.IMAGE,
|
type: AttachmentType.IMAGE,
|
||||||
preview: ""
|
preview: "",
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
if(editableDivRef.current){
|
if(editableDivRef.current){
|
||||||
@@ -304,7 +351,11 @@ export function DialogInput() {
|
|||||||
blob: fileContent,
|
blob: fileContent,
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
type: AttachmentType.FILE,
|
type: AttachmentType.FILE,
|
||||||
preview: files[0].size + "::" + files[0].name
|
preview: files[0].size + "::" + files[0].name,
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +396,12 @@ export function DialogInput() {
|
|||||||
{!blocked &&
|
{!blocked &&
|
||||||
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
|
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
|
||||||
<Flex w={25} mt={10} justify={'center'}>
|
<Flex w={25} mt={10} justify={'center'}>
|
||||||
|
{isRecording && (
|
||||||
|
<IconTrash onClick={stop} style={{
|
||||||
|
cursor: 'pointer'
|
||||||
|
}} color={colors.error} stroke={1.5} size={25}></IconTrash>
|
||||||
|
)}
|
||||||
|
{!isRecording && (
|
||||||
<Menu width={150} withArrow>
|
<Menu width={150} withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<IconPaperclip stroke={1.5} style={{
|
<IconPaperclip stroke={1.5} style={{
|
||||||
@@ -365,12 +422,14 @@ export function DialogInput() {
|
|||||||
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
|
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex
|
<Flex
|
||||||
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||||
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||||
align={'center'}
|
align={'center'}
|
||||||
>
|
>
|
||||||
|
{!isRecording && <>
|
||||||
<RichTextInput
|
<RichTextInput
|
||||||
ref={editableDivRef}
|
ref={editableDivRef}
|
||||||
style={{
|
style={{
|
||||||
@@ -387,19 +446,17 @@ export function DialogInput() {
|
|||||||
}}
|
}}
|
||||||
placeholder="Type message..."
|
placeholder="Type message..."
|
||||||
autoFocus
|
autoFocus
|
||||||
//ref={textareaRef}
|
|
||||||
//onPaste={onPaste}
|
|
||||||
//maxLength={2500}
|
|
||||||
//w={'100%'}
|
|
||||||
//h={'100%'}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onChange={setMessage}
|
onChange={setMessage}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
|
|
||||||
//dangerouslySetInnerHTML={{__html: message}}
|
|
||||||
></RichTextInput>
|
></RichTextInput>
|
||||||
|
</>}
|
||||||
|
{isRecording && <>
|
||||||
|
<VoiceRecorder duration={duration} waves={waves}></VoiceRecorder>
|
||||||
|
</>}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
|
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
|
||||||
|
{!isRecording && <>
|
||||||
<Popover withArrow>
|
<Popover withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
|
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
|
||||||
@@ -415,9 +472,42 @@ export function DialogInput() {
|
|||||||
/>
|
/>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
<IconSend stroke={1.5} color={message.trim() == "" && attachments.length <= 0 ? colors.chevrons.active : colors.brandColor} onClick={send} style={{
|
</>}
|
||||||
|
<Box pos="relative" ml={isRecording ? 35 : 0} w={25} h={25}>
|
||||||
|
<Transition mounted={showSendIcon} transition="pop" duration={180} timingFunction="ease">
|
||||||
|
{(styles) => (
|
||||||
|
<IconSend
|
||||||
|
stroke={1.5}
|
||||||
|
color={colors.brandColor}
|
||||||
|
onClick={send}
|
||||||
|
style={{
|
||||||
|
...styles,
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}} size={25}></IconSend>
|
}}
|
||||||
|
size={25}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition mounted={!showSendIcon} transition="pop" duration={180} timingFunction="ease">
|
||||||
|
{(styles) => (
|
||||||
|
<IconMicrophone
|
||||||
|
stroke={1.5}
|
||||||
|
color={colors.chevrons.active}
|
||||||
|
onClick={onMicroClick}
|
||||||
|
style={{
|
||||||
|
...styles,
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
size={25}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>}
|
</Flex>}
|
||||||
{blocked && <Box mih={62} bg={colors.boxColor}>
|
{blocked && <Box mih={62} bg={colors.boxColor}>
|
||||||
|
|||||||
273
app/components/DialogInput/useVoiceMessage.ts
Normal file
273
app/components/DialogInput/useVoiceMessage.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useVoiceMessage(): {
|
||||||
|
isRecording: boolean;
|
||||||
|
isPaused: boolean;
|
||||||
|
duration: number;
|
||||||
|
waves: number[];
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
play: () => void;
|
||||||
|
error: string | null;
|
||||||
|
getAudioBlob: () => Blob | null;
|
||||||
|
interpolateCompressWaves: (targetLength: number) => number[];
|
||||||
|
} {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [waves, setWaves] = useState<number[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const waveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
|
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
const waveDataRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
|
||||||
|
|
||||||
|
const clearTimer = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopWaveLoop = useCallback(() => {
|
||||||
|
if (waveTimerRef.current) {
|
||||||
|
clearInterval(waveTimerRef.current);
|
||||||
|
waveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTimer = useCallback(() => {
|
||||||
|
if (timerRef.current) return;
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setDuration((prev) => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startWaveLoop = useCallback(() => {
|
||||||
|
stopWaveLoop();
|
||||||
|
|
||||||
|
const analyser = analyserRef.current;
|
||||||
|
if (!analyser) return;
|
||||||
|
|
||||||
|
if (!waveDataRef.current || waveDataRef.current.length !== analyser.frequencyBinCount) {
|
||||||
|
waveDataRef.current = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_WAVES = 120;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!analyserRef.current || !waveDataRef.current) return;
|
||||||
|
|
||||||
|
analyserRef.current.getByteFrequencyData(waveDataRef.current);
|
||||||
|
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < waveDataRef.current.length; i++) {
|
||||||
|
const v = waveDataRef.current[i];
|
||||||
|
if (v > peak) peak = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bar = peak / 255;
|
||||||
|
|
||||||
|
setWaves((prev) => {
|
||||||
|
const next = [...prev, bar];
|
||||||
|
return next.length > MAX_WAVES ? next.slice(next.length - MAX_WAVES) : next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
waveTimerRef.current = setInterval(tick, 300);
|
||||||
|
}, [stopWaveLoop]);
|
||||||
|
|
||||||
|
const cleanupAudio = useCallback(() => {
|
||||||
|
stopWaveLoop();
|
||||||
|
|
||||||
|
sourceRef.current?.disconnect();
|
||||||
|
sourceRef.current = null;
|
||||||
|
analyserRef.current = null;
|
||||||
|
waveDataRef.current = null;
|
||||||
|
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
audioContextRef.current.close();
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}, [stopWaveLoop]);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setDuration(0);
|
||||||
|
setWaves([]);
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 2048;
|
||||||
|
analyser.smoothingTimeConstant = 0;
|
||||||
|
analyser.minDecibels = -100;
|
||||||
|
analyser.maxDecibels = -10;
|
||||||
|
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
analyserRef.current = analyser;
|
||||||
|
sourceRef.current = source;
|
||||||
|
|
||||||
|
// Выбираем лучший поддерживаемый кодек
|
||||||
|
const preferredTypes = [
|
||||||
|
"audio/webm;codecs=opus",
|
||||||
|
"audio/ogg;codecs=opus",
|
||||||
|
"audio/webm",
|
||||||
|
];
|
||||||
|
|
||||||
|
const mimeType = preferredTypes.find((t) => MediaRecorder.isTypeSupported(t)) ?? "";
|
||||||
|
|
||||||
|
const mediaRecorder = new MediaRecorder(stream, {
|
||||||
|
...(mimeType ? { mimeType } : {}),
|
||||||
|
audioBitsPerSecond: 32_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
cleanupAudio();
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start(100);
|
||||||
|
setIsRecording(true);
|
||||||
|
setIsPaused(false);
|
||||||
|
startTimer();
|
||||||
|
startWaveLoop();
|
||||||
|
} catch (err) {
|
||||||
|
setError("Could not start voice recording. Please check microphone permissions.");
|
||||||
|
console.error("Voice recording error:", err);
|
||||||
|
}
|
||||||
|
}, [startTimer, startWaveLoop, cleanupAudio]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
mediaRecorderRef.current = null;
|
||||||
|
setIsRecording(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
clearTimer();
|
||||||
|
stopWaveLoop();
|
||||||
|
}
|
||||||
|
}, [isRecording, clearTimer, stopWaveLoop]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
||||||
|
mediaRecorderRef.current.pause();
|
||||||
|
setIsPaused(true);
|
||||||
|
clearTimer();
|
||||||
|
stopWaveLoop();
|
||||||
|
}
|
||||||
|
}, [clearTimer, stopWaveLoop]);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "paused") {
|
||||||
|
mediaRecorderRef.current.resume();
|
||||||
|
setIsPaused(false);
|
||||||
|
startTimer();
|
||||||
|
startWaveLoop();
|
||||||
|
}
|
||||||
|
}, [startTimer, startWaveLoop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimer();
|
||||||
|
stopWaveLoop();
|
||||||
|
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current?.state !== "inactive") {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupAudio();
|
||||||
|
};
|
||||||
|
}, [clearTimer, stopWaveLoop, cleanupAudio]);
|
||||||
|
|
||||||
|
const getAudioBlob = useCallback((): Blob | null => {
|
||||||
|
if (chunksRef.current.length === 0) return null;
|
||||||
|
const mimeType = mediaRecorderRef.current?.mimeType ?? "audio/webm;codecs=opus";
|
||||||
|
return new Blob(chunksRef.current, { type: mimeType });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const interpolateCompressWaves = useCallback((targetLength: number) => {
|
||||||
|
if (targetLength <= 0) return [];
|
||||||
|
if (waves.length === 0) return Array(targetLength).fill(0);
|
||||||
|
if (waves.length === targetLength) return waves;
|
||||||
|
|
||||||
|
if (waves.length > targetLength) {
|
||||||
|
const compressed: number[] = [];
|
||||||
|
const bucketSize = waves.length / targetLength;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetLength; i++) {
|
||||||
|
const start = Math.floor(i * bucketSize);
|
||||||
|
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
|
||||||
|
|
||||||
|
let max = 0;
|
||||||
|
for (let j = start; j < end && j < waves.length; j++) {
|
||||||
|
if (waves[j] > max) max = waves[j];
|
||||||
|
}
|
||||||
|
|
||||||
|
compressed.push(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLength === 1) return [waves[0]];
|
||||||
|
|
||||||
|
const stretched: number[] = [];
|
||||||
|
const lastSourceIndex = waves.length - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetLength; i++) {
|
||||||
|
const position = (i * lastSourceIndex) / (targetLength - 1);
|
||||||
|
const left = Math.floor(position);
|
||||||
|
const right = Math.min(Math.ceil(position), lastSourceIndex);
|
||||||
|
|
||||||
|
if (left === right) {
|
||||||
|
stretched.push(waves[left]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = position - left;
|
||||||
|
const value = waves[left] * (1 - t) + waves[right] * t;
|
||||||
|
stretched.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stretched;
|
||||||
|
}, [waves]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecording,
|
||||||
|
isPaused,
|
||||||
|
duration,
|
||||||
|
waves,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
pause,
|
||||||
|
play,
|
||||||
|
error,
|
||||||
|
getAudioBlob,
|
||||||
|
interpolateCompressWaves,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu";
|
|||||||
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
|
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
|
||||||
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
|
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
|
||||||
import { useMentions } from "@/app/providers/DialogStateProvider.tsx/useMentions";
|
import { useMentions } from "@/app/providers/DialogStateProvider.tsx/useMentions";
|
||||||
|
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||||
|
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||||
|
|
||||||
export interface DialogProps extends DialogRow {
|
export interface DialogProps extends DialogRow {
|
||||||
onClickDialog: (dialog: string) => void;
|
onClickDialog: (dialog: string) => void;
|
||||||
@@ -54,6 +56,7 @@ export function GroupDialog(props : DialogProps) {
|
|||||||
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
|
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
|
||||||
const {openContextMenu} = useDialogContextMenu();
|
const {openContextMenu} = useDialogContextMenu();
|
||||||
const {isMentioned} = useMentions();
|
const {isMentioned} = useMentions();
|
||||||
|
const [protocolState] = useProtocolState();
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,7 +159,7 @@ export function GroupDialog(props : DialogProps) {
|
|||||||
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
|
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||||
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
|
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
|
||||||
)}
|
)}
|
||||||
{unreaded > 0 && !lastMessageFromMe && !isMentioned(props.dialog_id) && <Badge
|
{unreaded > 0 && !lastMessageFromMe && protocolState != ProtocolState.SYNCHRONIZATION && !isMentioned(props.dialog_id) && <Badge
|
||||||
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
|
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
|
||||||
c={isInCurrentDialog ? colors.brandColor : 'white'}
|
c={isInCurrentDialog ? colors.brandColor : 'white'}
|
||||||
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}
|
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AttachmentError } from "../AttachmentError/AttachmentError";
|
|||||||
import { MessageAvatar } from "./MessageAvatar";
|
import { MessageAvatar } from "./MessageAvatar";
|
||||||
import { MessageProps } from "../Messages/Message";
|
import { MessageProps } from "../Messages/Message";
|
||||||
import { MessageCall } from "./MessageCall";
|
import { MessageCall } from "./MessageCall";
|
||||||
|
import { MessageVoice } from "./MessageVoice";
|
||||||
|
|
||||||
export interface MessageAttachmentsProps {
|
export interface MessageAttachmentsProps {
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
@@ -54,6 +55,8 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
|
|||||||
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
||||||
case AttachmentType.CALL:
|
case AttachmentType.CALL:
|
||||||
return <MessageCall {...attachProps} key={index}></MessageCall>
|
return <MessageCall {...attachProps} key={index}></MessageCall>
|
||||||
|
case AttachmentType.VOICE:
|
||||||
|
return <MessageVoice {...attachProps} key={index}></MessageVoice>
|
||||||
default:
|
default:
|
||||||
return <AttachmentError key={index}></AttachmentError>;
|
return <AttachmentError key={index}></AttachmentError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { useAttachment } from "@/app/providers/AttachmentProvider/useAttachment"
|
|||||||
import { AttachmentProps } from "./MessageAttachments";
|
import { AttachmentProps } from "./MessageAttachments";
|
||||||
import { Avatar, Box, Flex, Text } from "@mantine/core";
|
import { Avatar, Box, Flex, Text } from "@mantine/core";
|
||||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||||
import { IconPhoneOutgoing, IconX } from "@tabler/icons-react";
|
import { IconPhoneIncoming, IconPhoneOutgoing, IconX } from "@tabler/icons-react";
|
||||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||||
|
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||||
|
|
||||||
export function MessageCall(props: AttachmentProps) {
|
export function MessageCall(props: AttachmentProps) {
|
||||||
const {
|
const {
|
||||||
@@ -13,9 +14,10 @@ export function MessageCall(props: AttachmentProps) {
|
|||||||
props.attachment,
|
props.attachment,
|
||||||
props.parent,
|
props.parent,
|
||||||
);
|
);
|
||||||
|
const publicKey = usePublicKey();
|
||||||
const preview = getPreview();
|
const preview = getPreview();
|
||||||
const callerRole = preview.split("::")[0];
|
const caller = props.parent.from == publicKey;
|
||||||
const duration = parseInt(preview.split("::")[1]);
|
const duration = parseInt(preview);
|
||||||
const colors = useRosettaColors();
|
const colors = useRosettaColors();
|
||||||
const error = duration == 0;
|
const error = duration == 0;
|
||||||
|
|
||||||
@@ -30,13 +32,11 @@ export function MessageCall(props: AttachmentProps) {
|
|||||||
<Flex gap={'sm'} direction={'row'}>
|
<Flex gap={'sm'} direction={'row'}>
|
||||||
<Avatar bg={error ? colors.error : colors.brandColor} size={40}>
|
<Avatar bg={error ? colors.error : colors.brandColor} size={40}>
|
||||||
{!error && <>
|
{!error && <>
|
||||||
{callerRole == "0" && (
|
{!caller && (
|
||||||
<IconPhoneOutgoing color={'white'} size={22}></IconPhoneOutgoing>
|
<IconPhoneIncoming color={'white'} size={22}></IconPhoneIncoming>
|
||||||
)}
|
)}
|
||||||
{callerRole == "1" && (
|
{caller && (
|
||||||
<IconPhoneOutgoing color={'white'} size={22} style={{
|
<IconPhoneOutgoing color={'white'} size={22}></IconPhoneOutgoing>
|
||||||
transform: 'rotate(180deg)'
|
|
||||||
}}></IconPhoneOutgoing>
|
|
||||||
)}
|
)}
|
||||||
</>}
|
</>}
|
||||||
{error && <>
|
{error && <>
|
||||||
@@ -45,7 +45,7 @@ export function MessageCall(props: AttachmentProps) {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<Flex direction={'column'} gap={5}>
|
<Flex direction={'column'} gap={5}>
|
||||||
<Text size={'sm'}>{
|
<Text size={'sm'}>{
|
||||||
error ? (callerRole == "0" ? "Missed call" : "Rejected call") : (callerRole == "0" ? "Incoming call" : "Outgoing call")
|
error ? (!caller ? "Missed call" : "Rejected call") : (!caller ? "Incoming call" : "Outgoing call")
|
||||||
}</Text>
|
}</Text>
|
||||||
{!error &&
|
{!error &&
|
||||||
<Text size={'xs'} c={colors.chevrons.active}>
|
<Text size={'xs'} c={colors.chevrons.active}>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function MessageImage(props: AttachmentProps) {
|
|||||||
const [blurhashPreview, setBlurhashPreview] = useState("");
|
const [blurhashPreview, setBlurhashPreview] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.info(props.attachment);
|
||||||
console.info("Consturcting image, download status: " + downloadStatus);
|
console.info("Consturcting image, download status: " + downloadStatus);
|
||||||
constructBlob();
|
constructBlob();
|
||||||
constructFromBlurhash();
|
constructFromBlurhash();
|
||||||
@@ -149,7 +150,7 @@ export function MessageImage(props: AttachmentProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Portal>}
|
</Portal>}
|
||||||
{(props.delivered == DeliveredMessageState.ERROR || (props.delivered != DeliveredMessageState.DELIVERED &&
|
{(props.delivered == DeliveredMessageState.ERROR || error || (props.delivered != DeliveredMessageState.DELIVERED &&
|
||||||
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
|
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
|
||||||
)) && (
|
)) && (
|
||||||
<Overlay center h={'100%'} radius={8} color="#000" opacity={0.85}>
|
<Overlay center h={'100%'} radius={8} color="#000" opacity={0.85}>
|
||||||
@@ -158,7 +159,7 @@ export function MessageImage(props: AttachmentProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.DOWNLOADING) && (<Flex direction={'column'} pos={'absolute'} top={0} justify={'center'} h={'100%'} align={'center'} gap={'xs'}>
|
{(downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.DOWNLOADING) && (<Flex direction={'column'} pos={'absolute'} top={0} justify={'center'} h={'100%'} align={'center'} gap={'xs'}>
|
||||||
{!error && (
|
{!error && downloadStatus == DownloadStatus.DOWNLOADING && (
|
||||||
<Box style={{
|
<Box style={{
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
@@ -168,11 +169,20 @@ export function MessageImage(props: AttachmentProps) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
{downloadPercentage > 0 ? (
|
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} color="white"></AnimatedRoundedProgress>
|
||||||
<AnimatedRoundedProgress size={40} value={downloadPercentage} color="white"></AnimatedRoundedProgress>
|
</Box>
|
||||||
) : (
|
|
||||||
<IconArrowDown size={25} color={'white'} />
|
|
||||||
)}
|
)}
|
||||||
|
{!error && downloadStatus != DownloadStatus.DOWNLOADING && (
|
||||||
|
<Box style={{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
borderRadius: 50,
|
||||||
|
height: 40,
|
||||||
|
width: 40,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<IconArrowDown size={25} color={'white'} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ReplyedMessage } from "../ReplyedMessage/ReplyedMessage";
|
|||||||
import { IconX } from "@tabler/icons-react";
|
import { IconX } from "@tabler/icons-react";
|
||||||
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
|
import { MessageReply } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||||
|
|
||||||
export function MessageReplyMessages(props: AttachmentProps) {
|
export function MessageReplyMessages(props: AttachmentProps) {
|
||||||
const colors = useRosettaColors();
|
const colors = useRosettaColors();
|
||||||
@@ -12,9 +13,7 @@ export function MessageReplyMessages(props: AttachmentProps) {
|
|||||||
('showAlertInReplyMessages', true);
|
('showAlertInReplyMessages', true);
|
||||||
const [bgInReplyMessages] = useSetting<string>
|
const [bgInReplyMessages] = useSetting<string>
|
||||||
('bgInReplyMessages', '');
|
('bgInReplyMessages', '');
|
||||||
const reply = JSON.parse(props.attachment.blob);
|
const reply = JSON.parse(props.attachment.blob) as MessageReply[];
|
||||||
|
|
||||||
//console.info("Mreply", reply);
|
|
||||||
|
|
||||||
const closeAlert = () => {
|
const closeAlert = () => {
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
@@ -40,8 +39,8 @@ export function MessageReplyMessages(props: AttachmentProps) {
|
|||||||
{reply.length <= 0 &&
|
{reply.length <= 0 &&
|
||||||
<Skeleton h={50} w={'100%'}></Skeleton>
|
<Skeleton h={50} w={'100%'}></Skeleton>
|
||||||
}
|
}
|
||||||
{reply.map((msg, index) => (
|
{reply.map((msg : MessageReply, index) => (
|
||||||
<ReplyedMessage parent={props.parent} chacha_key_plain={props.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
|
<ReplyedMessage parent={props.parent} chacha_key_plain={msg.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
|
||||||
))}
|
))}
|
||||||
{showAlertInReplyMessages && <Alert style={{
|
{showAlertInReplyMessages && <Alert style={{
|
||||||
borderTopLeftRadius: 0,
|
borderTopLeftRadius: 0,
|
||||||
|
|||||||
267
app/components/MessageAttachments/MessageVoice.tsx
Normal file
267
app/components/MessageAttachments/MessageVoice.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
|
||||||
|
import { AttachmentProps } from "./MessageAttachments";
|
||||||
|
import { Avatar, Box, Flex, Text, useMantineTheme } from "@mantine/core";
|
||||||
|
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
|
||||||
|
import { IconArrowDown, IconPlayerPauseFilled, IconPlayerPlayFilled, IconX } from "@tabler/icons-react";
|
||||||
|
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||||
|
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { usePlayerContext } from "@/app/providers/PlayerProvider/usePlayerContext";
|
||||||
|
|
||||||
|
const WAVE_BARS = 40;
|
||||||
|
const BAR_WIDTH = 2;
|
||||||
|
const BAR_GAP = 2;
|
||||||
|
const MIN_BAR_HEIGHT = 4;
|
||||||
|
const MAX_BAR_HEIGHT = 24;
|
||||||
|
|
||||||
|
function normalizeWaves(source: number[], targetLength: number): number[] {
|
||||||
|
if (targetLength <= 0) return [];
|
||||||
|
if (source.length === 0) return Array(targetLength).fill(0);
|
||||||
|
if (source.length === targetLength) return source;
|
||||||
|
|
||||||
|
if (source.length > targetLength) {
|
||||||
|
const compressed: number[] = [];
|
||||||
|
const bucketSize = source.length / targetLength;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetLength; i++) {
|
||||||
|
const start = Math.floor(i * bucketSize);
|
||||||
|
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
|
||||||
|
|
||||||
|
let max = 0;
|
||||||
|
for (let j = start; j < end && j < source.length; j++) {
|
||||||
|
if (source[j] > max) max = source[j];
|
||||||
|
}
|
||||||
|
|
||||||
|
compressed.push(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLength === 1) return [source[0]];
|
||||||
|
|
||||||
|
const stretched: number[] = [];
|
||||||
|
const lastSourceIndex = source.length - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetLength; i++) {
|
||||||
|
const position = (i * lastSourceIndex) / (targetLength - 1);
|
||||||
|
const left = Math.floor(position);
|
||||||
|
const right = Math.min(Math.ceil(position), lastSourceIndex);
|
||||||
|
|
||||||
|
if (left === right) {
|
||||||
|
stretched.push(source[left]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = position - left;
|
||||||
|
stretched.push(source[left] * (1 - t) + source[right] * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stretched;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
const s = Math.max(0, Math.floor(seconds));
|
||||||
|
const m = Math.floor(s / 60).toString().padStart(2, "0");
|
||||||
|
const r = (s % 60).toString().padStart(2, "0");
|
||||||
|
return `${m}:${r}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageVoice(props: AttachmentProps) {
|
||||||
|
const { downloadPercentage, downloadStatus, uploadedPercentage, download, getPreview } = useAttachment(
|
||||||
|
props.attachment,
|
||||||
|
props.parent,
|
||||||
|
);
|
||||||
|
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const colors = useRosettaColors();
|
||||||
|
|
||||||
|
const preview = getPreview() || "";
|
||||||
|
const [durationPart = "0", wavesPart = ""] = preview.split("::");
|
||||||
|
const previewDuration = Number.parseInt(durationPart, 10) || 0;
|
||||||
|
|
||||||
|
const rawWaves = useMemo(
|
||||||
|
() =>
|
||||||
|
wavesPart
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number.parseFloat(s))
|
||||||
|
.filter((n) => Number.isFinite(n) && n >= 0),
|
||||||
|
[wavesPart]
|
||||||
|
);
|
||||||
|
|
||||||
|
const waves = useMemo(() => normalizeWaves(rawWaves, WAVE_BARS), [rawWaves]);
|
||||||
|
|
||||||
|
const peak = useMemo(() => {
|
||||||
|
const max = Math.max(...waves, 0);
|
||||||
|
return max > 0 ? max : 1;
|
||||||
|
}, [waves]);
|
||||||
|
|
||||||
|
const isUploading =
|
||||||
|
props.delivered === DeliveredMessageState.WAITING &&
|
||||||
|
uploadedPercentage > 0 &&
|
||||||
|
uploadedPercentage < 100;
|
||||||
|
const error = downloadStatus === DownloadStatus.ERROR;
|
||||||
|
|
||||||
|
const waveformWidth = WAVE_BARS * BAR_WIDTH + (WAVE_BARS - 1) * BAR_GAP;
|
||||||
|
const waveformRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
playAudio,
|
||||||
|
pause,
|
||||||
|
duration: currentDuration,
|
||||||
|
playing,
|
||||||
|
setDuration,
|
||||||
|
totalDuration,
|
||||||
|
currentMessageId,
|
||||||
|
} = usePlayerContext();
|
||||||
|
const messageId = String((props.parent as any)?.id ?? (props.attachment as any)?.messageId ?? props.attachment.id);
|
||||||
|
const isCurrentTrack = currentMessageId === messageId;
|
||||||
|
|
||||||
|
const fullDuration = Math.max(isCurrentTrack && totalDuration > 0 ? totalDuration : previewDuration, 1);
|
||||||
|
const safeCurrent = isCurrentTrack ? currentDuration : 0;
|
||||||
|
const playbackProgress = Math.max(0, Math.min(1, safeCurrent / fullDuration));
|
||||||
|
|
||||||
|
const createAudioBlob = () => new Blob([Buffer.from(props.attachment.blob, "hex")], { type: "audio/webm;codecs=opus" });
|
||||||
|
|
||||||
|
const ensureStarted = (seekToSec?: number) => {
|
||||||
|
const blob = createAudioBlob();
|
||||||
|
playAudio("Voice Message", "", blob, messageId);
|
||||||
|
|
||||||
|
if (typeof seekToSec === "number") {
|
||||||
|
requestAnimationFrame(() => setDuration(seekToSec));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMainAction = () => {
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
if (downloadStatus !== DownloadStatus.DOWNLOADED) {
|
||||||
|
download();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentTrack) {
|
||||||
|
ensureStarted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureStarted(Math.max(0, safeCurrent));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (error || downloadStatus !== DownloadStatus.DOWNLOADED) return;
|
||||||
|
|
||||||
|
const rect = waveformRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect || rect.width <= 0) return;
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const progress = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const seekTo = progress * fullDuration;
|
||||||
|
|
||||||
|
if (!isCurrentTrack) {
|
||||||
|
ensureStarted(seekTo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDuration(seekTo);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeText =
|
||||||
|
isCurrentTrack && safeCurrent > 0
|
||||||
|
? `-${formatTime(Math.max(0, fullDuration - safeCurrent))}`
|
||||||
|
: formatTime(fullDuration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="sm" align="center">
|
||||||
|
<Avatar
|
||||||
|
bg={error ? colors.error : colors.brandColor}
|
||||||
|
size={40}
|
||||||
|
style={{ cursor: "pointer", position: "relative" }}
|
||||||
|
onClick={handleMainAction}
|
||||||
|
>
|
||||||
|
{!error && (
|
||||||
|
<>
|
||||||
|
{downloadStatus === DownloadStatus.DOWNLOADING && (
|
||||||
|
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||||
|
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||||
|
<AnimatedRoundedProgress color="#fff" size={40} value={uploadedPercentage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{downloadStatus !== DownloadStatus.DOWNLOADED && <IconArrowDown color="white" size={22} />}
|
||||||
|
|
||||||
|
{downloadStatus === DownloadStatus.DOWNLOADED && !isUploading &&
|
||||||
|
(isCurrentTrack && playing ? (
|
||||||
|
<IconPlayerPauseFilled color="white" size={22} />
|
||||||
|
) : (
|
||||||
|
<IconPlayerPlayFilled color="white" size={22} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || isUploading) && <IconX color="white" size={22} />}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Flex direction="column">
|
||||||
|
<Box
|
||||||
|
ref={waveformRef}
|
||||||
|
w={waveformWidth}
|
||||||
|
h={32}
|
||||||
|
onClick={handleSeek}
|
||||||
|
style={{ overflow: "hidden", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<Flex h="100%" align="center" gap={BAR_GAP} wrap="nowrap">
|
||||||
|
{waves.map((value, index) => {
|
||||||
|
const normalized = Math.max(0, Math.min(1, value / peak));
|
||||||
|
const height = Math.max(
|
||||||
|
MIN_BAR_HEIGHT,
|
||||||
|
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
|
||||||
|
);
|
||||||
|
|
||||||
|
const passed = playbackProgress * waves.length - index;
|
||||||
|
const fillPercent = Math.max(0, Math.min(1, passed));
|
||||||
|
|
||||||
|
const inactiveColor = theme.colors.gray[4];
|
||||||
|
const activeColor = colors.brandColor;
|
||||||
|
|
||||||
|
let background = inactiveColor;
|
||||||
|
if (fillPercent >= 1) {
|
||||||
|
background = activeColor;
|
||||||
|
} else if (fillPercent > 0) {
|
||||||
|
background = `linear-gradient(90deg, ${activeColor} 0%, ${activeColor} ${fillPercent * 100}%, ${inactiveColor} ${fillPercent * 100}%, ${inactiveColor} 100%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={index}
|
||||||
|
w={BAR_WIDTH}
|
||||||
|
h={height}
|
||||||
|
style={{
|
||||||
|
flex: `0 0 ${BAR_WIDTH}px`,
|
||||||
|
borderRadius: 999,
|
||||||
|
background,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{timeText}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -103,7 +103,11 @@ export function Message(props: MessageProps) {
|
|||||||
publicKey: user.publicKey,
|
publicKey: user.publicKey,
|
||||||
message: props.message,
|
message: props.message,
|
||||||
attachments: props.attachments.filter(a => a.type != AttachmentType.MESSAGES),
|
attachments: props.attachments.filter(a => a.type != AttachmentType.MESSAGES),
|
||||||
message_id: props.message_id
|
message_id: props.message_id,
|
||||||
|
/**
|
||||||
|
* Кодируем в hex чтобы было удобнее передавать по сети
|
||||||
|
*/
|
||||||
|
chacha_key_plain: props.chacha_key_plain
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatars = useAvatars(user.publicKey);
|
const avatars = useAvatars(user.publicKey);
|
||||||
@@ -125,6 +129,9 @@ export function Message(props: MessageProps) {
|
|||||||
if (props.replyed) {
|
if (props.replyed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if(props.chacha_key_plain == ""){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) {
|
if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface SettingsInputDefaultProps {
|
|||||||
mt?: StyleProp<MantineSpacing>;
|
mt?: StyleProp<MantineSpacing>;
|
||||||
rightSection?: ReactNode;
|
rightSection?: ReactNode;
|
||||||
type?: HTMLInputTypeAttribute;
|
type?: HTMLInputTypeAttribute;
|
||||||
|
onErrorStateChange?: (error: boolean) => void;
|
||||||
|
regexp?: RegExp;
|
||||||
}
|
}
|
||||||
export interface SettingsInputGroupProps {
|
export interface SettingsInputGroupProps {
|
||||||
mt?: StyleProp<MantineSpacing>;
|
mt?: StyleProp<MantineSpacing>;
|
||||||
@@ -260,7 +262,6 @@ function SettingsInputClickable(
|
|||||||
function SettingsInputDefault(props : SettingsInputDefaultProps) {
|
function SettingsInputDefault(props : SettingsInputDefaultProps) {
|
||||||
const colors = useRosettaColors();
|
const colors = useRosettaColors();
|
||||||
const input = useRef<any>(undefined);
|
const input = useRef<any>(undefined);
|
||||||
|
|
||||||
const onClick = (e : MouseEvent) => {
|
const onClick = (e : MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if(!props.disabled){
|
if(!props.disabled){
|
||||||
@@ -268,6 +269,19 @@ function SettingsInputDefault(props : SettingsInputDefaultProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
let value = e.target.value;
|
||||||
|
if(props.regexp && !props.regexp.test(value)) {
|
||||||
|
props.onErrorStateChange && props.onErrorStateChange(true);
|
||||||
|
props.onChange && props.onChange(e);
|
||||||
|
}else{
|
||||||
|
props.onErrorStateChange && props.onErrorStateChange(false);
|
||||||
|
console.info('fa');
|
||||||
|
props.onChange && props.onChange(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (<>
|
return (<>
|
||||||
<Paper mt={props.mt} style={props.style} withBorder styles={{
|
<Paper mt={props.mt} style={props.style} withBorder styles={{
|
||||||
root: {
|
root: {
|
||||||
@@ -298,7 +312,7 @@ function SettingsInputDefault(props : SettingsInputDefaultProps) {
|
|||||||
{!props.rightSection && (
|
{!props.rightSection && (
|
||||||
<Input type={props.type} defaultValue={!props.onChange ? props.value : undefined} value={!props.onChange ? undefined : props.value} ref={input} disabled={props.disabled} onClick={(e) => {
|
<Input type={props.type} defaultValue={!props.onChange ? props.value : undefined} value={!props.onChange ? undefined : props.value} ref={input} disabled={props.disabled} onClick={(e) => {
|
||||||
onClick(e)
|
onClick(e)
|
||||||
}} onChange={props.onChange} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
|
}} onChange={onChange} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
|
||||||
input: classes.input
|
input: classes.input
|
||||||
}} placeholder={props.placeholder}></Input>)
|
}} placeholder={props.placeholder}></Input>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export function TextParser(props: TextParserProps) {
|
|||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
let entityCount = 0;
|
let entityCount = 0;
|
||||||
|
|
||||||
|
const UNICODE_EMOJI_SEQUENCE_REGEX =
|
||||||
|
/(?:\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
|
||||||
|
|
||||||
|
const UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL = new RegExp(
|
||||||
|
UNICODE_EMOJI_SEQUENCE_REGEX.source,
|
||||||
|
"gu"
|
||||||
|
);
|
||||||
|
|
||||||
|
const toUnified = (value: string): string =>
|
||||||
|
Array.from(value)
|
||||||
|
.map((ch) => ch.codePointAt(0)?.toString(16))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("-");
|
||||||
|
|
||||||
const formatRules : FormatRule[] = [
|
const formatRules : FormatRule[] = [
|
||||||
{
|
{
|
||||||
pattern: [
|
pattern: [
|
||||||
@@ -58,7 +72,7 @@ export function TextParser(props: TextParserProps) {
|
|||||||
return <Anchor style={{
|
return <Anchor style={{
|
||||||
userSelect: 'auto',
|
userSelect: 'auto',
|
||||||
color: props.__reserved_2 ? theme.colors.blue[2] : undefined
|
color: props.__reserved_2 ? theme.colors.blue[2] : undefined
|
||||||
}} fz={14} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
|
}} fz={13} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
|
||||||
},
|
},
|
||||||
flush: (match: string) => {
|
flush: (match: string) => {
|
||||||
return <>{match}</>;
|
return <>{match}</>;
|
||||||
@@ -119,6 +133,23 @@ export function TextParser(props: TextParserProps) {
|
|||||||
return <>{match}</>;
|
return <>{match}</>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// unicode emojis (including composite sequences)
|
||||||
|
pattern: [UNICODE_EMOJI_SEQUENCE_REGEX],
|
||||||
|
render: (match: string) => {
|
||||||
|
const textWithoutEmojis = props.text.replace(UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL, "");
|
||||||
|
const unified = toUnified(match);
|
||||||
|
|
||||||
|
if (textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
|
||||||
|
return <Emoji size={40} unified={unified}></Emoji>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Emoji unified={unified}></Emoji>;
|
||||||
|
},
|
||||||
|
flush: (match: string) => {
|
||||||
|
return <Emoji unified={toUnified(match)}></Emoji>;
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// :emoji_code:
|
// :emoji_code:
|
||||||
pattern: [/:emoji_([a-zA-Z0-9_-]+):/],
|
pattern: [/:emoji_([a-zA-Z0-9_-]+):/],
|
||||||
|
|||||||
@@ -5,15 +5,23 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useUserInformation } from '@/app/providers/InformationProvider/useUserInformation';
|
import { useUserInformation } from '@/app/providers/InformationProvider/useUserInformation';
|
||||||
import { usePublicKey } from '@/app/providers/AccountProvider/usePublicKey';
|
import { usePublicKey } from '@/app/providers/AccountProvider/usePublicKey';
|
||||||
import { useAvatars } from '@/app/providers/AvatarProvider/useAvatars';
|
import { useAvatars } from '@/app/providers/AvatarProvider/useAvatars';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export function UserButton() {
|
export function UserButton() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const publicKey = usePublicKey();
|
const publicKey = usePublicKey();
|
||||||
const [userInfo] = useUserInformation(publicKey);
|
const [userInfo, _, forceUpdateUserInformation] = useUserInformation(publicKey);
|
||||||
const avatars = useAvatars(publicKey);
|
const avatars = useAvatars(publicKey);
|
||||||
|
|
||||||
const loading = userInfo.publicKey !== publicKey;
|
const loading = userInfo.publicKey !== publicKey;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* Обновляем информацию о пользователе принудительно при рендере левого меню
|
||||||
|
*/
|
||||||
|
forceUpdateUserInformation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton p={'sm'} className={classes.user} onClick={() => navigate("/main/profile/me")}>
|
<UnstyledButton p={'sm'} className={classes.user} onClick={() => navigate("/main/profile/me")}>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
278
app/components/VoiceRecorder/VoiceRecorder.tsx
Normal file
278
app/components/VoiceRecorder/VoiceRecorder.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { Box, Flex, Text, useMantineTheme } from "@mantine/core";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface VoiceRecorderProps {
|
||||||
|
duration: number;
|
||||||
|
waves: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnimatedBar = {
|
||||||
|
id: number;
|
||||||
|
value: number;
|
||||||
|
entered: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISIBLE_BARS = 50;
|
||||||
|
const BAR_WIDTH = 3;
|
||||||
|
const BAR_GAP = 2;
|
||||||
|
const STEP_PX = BAR_WIDTH + BAR_GAP;
|
||||||
|
const COMPONENT_HEIGHT = 45;
|
||||||
|
const MAX_BAR_HEIGHT = 28;
|
||||||
|
const MIN_BAR_HEIGHT = 4;
|
||||||
|
|
||||||
|
export function VoiceRecorder(props: VoiceRecorderProps) {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const [bars, setBars] = useState<AnimatedBar[]>([]);
|
||||||
|
const [subShift, setSubShift] = useState(0);
|
||||||
|
|
||||||
|
const prevLengthRef = useRef(0);
|
||||||
|
const prevWavesRef = useRef<number[]>([]);
|
||||||
|
const idRef = useRef(0);
|
||||||
|
const enterFrameRef = useRef<number | null>(null);
|
||||||
|
const scrollFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const lastAppendAtRef = useRef<number | null>(null);
|
||||||
|
const appendIntervalRef = useRef(120);
|
||||||
|
const barsLengthRef = useRef(0);
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
const secs = (seconds % 60).toString().padStart(2, "0");
|
||||||
|
return `${mins}:${secs}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
barsLengthRef.current = bars.length;
|
||||||
|
}, [bars.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.waves.length === 0) {
|
||||||
|
setBars([]);
|
||||||
|
setSubShift(0);
|
||||||
|
prevLengthRef.current = 0;
|
||||||
|
prevWavesRef.current = [];
|
||||||
|
lastAppendAtRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.waves.length < prevLengthRef.current) {
|
||||||
|
const resetBars = props.waves.slice(-VISIBLE_BARS).map((value) => ({
|
||||||
|
id: idRef.current++,
|
||||||
|
value,
|
||||||
|
entered: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setBars(resetBars);
|
||||||
|
setSubShift(0);
|
||||||
|
prevLengthRef.current = props.waves.length;
|
||||||
|
prevWavesRef.current = props.waves;
|
||||||
|
lastAppendAtRef.current = performance.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevWaves = prevWavesRef.current;
|
||||||
|
let appended: number[] = [];
|
||||||
|
|
||||||
|
// Обычный режим: длина выросла
|
||||||
|
if (props.waves.length > prevLengthRef.current) {
|
||||||
|
appended = props.waves.slice(prevLengthRef.current);
|
||||||
|
} else if (props.waves.length === prevLengthRef.current && props.waves.length > 0) {
|
||||||
|
// Rolling buffer: длина та же, но данные сдвигаются
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (prevWaves.length !== props.waves.length) {
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < props.waves.length; i++) {
|
||||||
|
if (props.waves[i] !== prevWaves[i]) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
appended = [props.waves[props.waves.length - 1]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appended.length > 0) {
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
if (lastAppendAtRef.current != null) {
|
||||||
|
const dt = now - lastAppendAtRef.current;
|
||||||
|
const perBar = dt / appended.length;
|
||||||
|
appendIntervalRef.current = appendIntervalRef.current * 0.7 + perBar * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAppendAtRef.current = now;
|
||||||
|
setSubShift(0);
|
||||||
|
|
||||||
|
const newIds: number[] = [];
|
||||||
|
|
||||||
|
setBars((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
|
||||||
|
appended.forEach((value) => {
|
||||||
|
const id = idRef.current++;
|
||||||
|
newIds.push(id);
|
||||||
|
|
||||||
|
next.push({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
entered: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return next.slice(-VISIBLE_BARS);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (enterFrameRef.current) {
|
||||||
|
cancelAnimationFrame(enterFrameRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
enterFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
setBars((prev) => {
|
||||||
|
const ids = new Set(newIds);
|
||||||
|
return prev.map((bar) => (ids.has(bar.id) ? { ...bar, entered: true } : bar));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prevLengthRef.current = props.waves.length;
|
||||||
|
prevWavesRef.current = props.waves;
|
||||||
|
}, [props.waves]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => {
|
||||||
|
const startedAt = lastAppendAtRef.current;
|
||||||
|
|
||||||
|
if (startedAt != null) {
|
||||||
|
const elapsed = performance.now() - startedAt;
|
||||||
|
const interval = Math.max(16, appendIntervalRef.current);
|
||||||
|
const progress = Math.min(1, elapsed / interval);
|
||||||
|
|
||||||
|
const smoothShift = barsLengthRef.current >= VISIBLE_BARS ? -progress * STEP_PX : 0;
|
||||||
|
setSubShift(smoothShift);
|
||||||
|
} else {
|
||||||
|
setSubShift(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (scrollFrameRef.current) {
|
||||||
|
cancelAnimationFrame(scrollFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (enterFrameRef.current) {
|
||||||
|
cancelAnimationFrame(enterFrameRef.current);
|
||||||
|
}
|
||||||
|
if (scrollFrameRef.current) {
|
||||||
|
cancelAnimationFrame(scrollFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const waveformWidth = VISIBLE_BARS * BAR_WIDTH + (VISIBLE_BARS - 1) * BAR_GAP;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction="row"
|
||||||
|
h={COMPONENT_HEIGHT}
|
||||||
|
mih={COMPONENT_HEIGHT}
|
||||||
|
mah={COMPONENT_HEIGHT}
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap="xs"
|
||||||
|
px={6}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed" w={36}>
|
||||||
|
{formatDuration(props.duration)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
w={waveformWidth}
|
||||||
|
h={COMPONENT_HEIGHT}
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
h="100%"
|
||||||
|
align="center"
|
||||||
|
gap={BAR_GAP}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ transform: `translateX(${subShift}px)` }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: VISIBLE_BARS }).map((_, index) => {
|
||||||
|
const bar = bars[index];
|
||||||
|
|
||||||
|
if (!bar) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={`empty-${index}`}
|
||||||
|
w={BAR_WIDTH}
|
||||||
|
h={MIN_BAR_HEIGHT}
|
||||||
|
style={{
|
||||||
|
flex: `0 0 ${BAR_WIDTH}px`,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: theme.colors.gray[3],
|
||||||
|
opacity: 0.22,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = Math.max(0, Math.min(1, bar.value));
|
||||||
|
const height = Math.max(
|
||||||
|
MIN_BAR_HEIGHT,
|
||||||
|
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
|
||||||
|
);
|
||||||
|
const isLast = index === bars.length - 1;
|
||||||
|
const isNearTail = index >= bars.length - 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={bar.id}
|
||||||
|
w={BAR_WIDTH}
|
||||||
|
h={height}
|
||||||
|
style={{
|
||||||
|
flex: `0 0 ${BAR_WIDTH}px`,
|
||||||
|
alignSelf: "center",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: isLast
|
||||||
|
? `linear-gradient(180deg, ${theme.colors.blue[3]} 0%, ${theme.colors.blue[5]} 100%)`
|
||||||
|
: `linear-gradient(180deg, ${theme.colors.blue[4]} 0%, ${theme.colors.blue[6]} 100%)`,
|
||||||
|
boxShadow: isLast
|
||||||
|
? `0 0 10px ${theme.colors.blue[4]}55`
|
||||||
|
: isNearTail
|
||||||
|
? `0 0 6px ${theme.colors.blue[4]}22`
|
||||||
|
: "none",
|
||||||
|
transform: bar.entered ? "scaleY(1)" : "scaleY(0.18)",
|
||||||
|
transformOrigin: "center center",
|
||||||
|
transition: [
|
||||||
|
"height 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
|
"transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
|
"opacity 220ms ease",
|
||||||
|
"box-shadow 220ms ease",
|
||||||
|
].join(", "),
|
||||||
|
willChange: "height, transform, opacity",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,5 +57,7 @@ export const ALLOWED_DOMAINS_ZONES = [
|
|||||||
'fm',
|
'fm',
|
||||||
'tv',
|
'tv',
|
||||||
'im',
|
'im',
|
||||||
'sc'
|
'sc',
|
||||||
|
'su',
|
||||||
|
'by'
|
||||||
];
|
];
|
||||||
@@ -27,17 +27,16 @@ export enum DownloadStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAttachment(attachment: Attachment, parentMessage: MessageProps) {
|
export function useAttachment(attachment: Attachment, parentMessage: MessageProps) {
|
||||||
const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
||||||
const uploadedPercentage = useUploadStatus(attachment.id);
|
const uploadedPercentage = useUploadStatus(attachment.id);
|
||||||
const downloadPercentage = useDownloadStatus(attachment.id);
|
const downloadPercentage = useDownloadStatus(attachment.id);
|
||||||
const [downloadStatus, setDownloadStatus] = useMemory("attachment-downloaded-status-" + attachment.id, DownloadStatus.PENDING, true);
|
const [downloadStatus, setDownloadStatus] = useMemory("attachment-downloaded-status-" + attachment.id, DownloadStatus.PENDING, true);
|
||||||
const [downloadTag, setDownloadTag] = useState("");
|
const [downloadTag, setDownloadTag] = useState(attachment.transport.transport_tag || "");
|
||||||
const {readFile, writeFile, fileExists, size} = useFileStorage();
|
const {readFile, writeFile, fileExists, size} = useFileStorage();
|
||||||
const { downloadFile } = useTransport();
|
const { downloadFile } = useTransport();
|
||||||
const publicKey = usePublicKey();
|
const publicKey = usePublicKey();
|
||||||
const privatePlain = usePrivatePlain();
|
const privatePlain = usePrivatePlain();
|
||||||
const {updateAttachmentInDialogCache} = useDialogsCache();
|
const {updateAttachmentInDialogCache} = useDialogsCache();
|
||||||
const {info} = useConsoleLogger('useAttachment');
|
const { info, error } = useConsoleLogger('useAttachment');
|
||||||
const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
|
const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
|
||||||
const {getDownloadsPath} = useCore();
|
const {getDownloadsPath} = useCore();
|
||||||
const {hasGroup} = useGroups();
|
const {hasGroup} = useGroups();
|
||||||
@@ -50,36 +49,26 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getPreview = () => {
|
const getPreview = () => {
|
||||||
if(attachment.preview.split("::")[0].match(uuidRegex)){
|
|
||||||
/**
|
|
||||||
* Это тег загрузки
|
|
||||||
*/
|
|
||||||
return attachment.preview.split("::").splice(1).join("::");
|
|
||||||
}
|
|
||||||
return attachment.preview;
|
return attachment.preview;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calcDownloadStatus = async () => {
|
const calcDownloadStatus = async () => {
|
||||||
if(attachment.preview.split("::")[0].match(uuidRegex)){
|
console.info("ds", attachment);
|
||||||
/**
|
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||||
* Это тег загрузки
|
|
||||||
*/
|
|
||||||
setDownloadTag(attachment.preview.split("::")[0]);
|
|
||||||
}
|
|
||||||
if(!attachment.preview.split("::")[0].match(uuidRegex)){
|
|
||||||
/**
|
|
||||||
* Там не тег загрузки, значит это наш файл
|
|
||||||
*/
|
|
||||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
if(attachment.transport.transport_tag == ""){
|
||||||
|
/**
|
||||||
|
* Транспортного тега нет только у сообщений отправленных нами, значит он точно наш
|
||||||
|
*/
|
||||||
|
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(attachment.type == AttachmentType.FILE){
|
if(attachment.type == AttachmentType.FILE){
|
||||||
/**
|
/**
|
||||||
* Если это файл, то он хранится не в папке медиа,
|
* Если это файл, то он хранится не в папке медиа,
|
||||||
* а в загрузках
|
* а в загрузках, статус скачивания определяем не только по названию файла,
|
||||||
|
* но и по его размеру (если размеры и название совпало, то считаем файл скаченным)
|
||||||
*/
|
*/
|
||||||
const preview = getPreview();
|
const preview = getPreview();
|
||||||
const filesize = parseInt(preview.split("::")[0]);
|
const filesize = parseInt(preview.split("::")[0]);
|
||||||
@@ -89,6 +78,9 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
|||||||
const exists = await fileExists(pathInDownloads, false);
|
const exists = await fileExists(pathInDownloads, false);
|
||||||
const existsLength = await size(pathInDownloads, false);
|
const existsLength = await size(pathInDownloads, false);
|
||||||
if(exists && existsLength == filesize){
|
if(exists && existsLength == filesize){
|
||||||
|
/**
|
||||||
|
* Если название файла и его размер совпадают (и он существует), то считаем его скаченным
|
||||||
|
*/
|
||||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,14 +135,14 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
|||||||
let downloadedBlob = '';
|
let downloadedBlob = '';
|
||||||
try {
|
try {
|
||||||
downloadedBlob = await downloadFile(attachment.id,
|
downloadedBlob = await downloadFile(attachment.id,
|
||||||
downloadTag);
|
downloadTag, attachment.transport.transport_server);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.info(e);
|
error("Error downloading attachment: " + attachment.id);
|
||||||
info("Error downloading attachment: " + attachment.id);
|
|
||||||
setDownloadStatus(DownloadStatus.ERROR);
|
setDownloadStatus(DownloadStatus.ERROR);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDownloadStatus(DownloadStatus.DECRYPTING);
|
setDownloadStatus(DownloadStatus.DECRYPTING);
|
||||||
|
console.info("decoding with key " + parentMessage.chacha_key_plain);
|
||||||
//console.info("Decrypted attachment ", Buffer.from(keyPlain, 'binary').toString('hex'));
|
//console.info("Decrypted attachment ", Buffer.from(keyPlain, 'binary').toString('hex'));
|
||||||
const decrypted = await decodeWithPassword(parentMessage.chacha_key_plain, downloadedBlob);
|
const decrypted = await decodeWithPassword(parentMessage.chacha_key_plain, downloadedBlob);
|
||||||
setDownloadTag("");
|
setDownloadTag("");
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
|||||||
import { useDatabase } from "../DatabaseProvider/useDatabase";
|
import { useDatabase } from "../DatabaseProvider/useDatabase";
|
||||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||||
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
|
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
|
||||||
import { DialogContext } from "../DialogProvider/DialogProvider";
|
import { AttachmentMeta, DialogContext } from "../DialogProvider/DialogProvider";
|
||||||
|
import { useTransportServer } from "../TransportProvider/useTransportServer";
|
||||||
|
|
||||||
export function usePrepareAttachment() {
|
export function usePrepareAttachment() {
|
||||||
const intervalsRef = useRef<NodeJS.Timeout>(null);
|
const intervalsRef = useRef<NodeJS.Timeout>(null);
|
||||||
const {uploadFile} = useTransport();
|
const {uploadFile} = useTransport();
|
||||||
const {updateDialog} = useDialogsList();
|
const {updateDialog} = useDialogsList();
|
||||||
const {runQuery} = useDatabase();
|
const {runQuery, getQuery} = useDatabase();
|
||||||
const {info} = useConsoleLogger('usePrepareAttachment');
|
const {info, error} = useConsoleLogger('usePrepareAttachment');
|
||||||
const {getDialogCache} = useDialogsCache();
|
const {getDialogCache} = useDialogsCache();
|
||||||
const context = useContext(DialogContext);
|
const context = useContext(DialogContext);
|
||||||
|
const transportServer = useTransportServer();
|
||||||
|
|
||||||
const updateTimestampInDialogCache = (dialog : string, message_id: string) => {
|
const updateTimestampInDialogCache = (dialog : string, message_id: string) => {
|
||||||
const dialogCache = getDialogCache(dialog);
|
const dialogCache = getDialogCache(dialog);
|
||||||
@@ -33,6 +35,79 @@ export function usePrepareAttachment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет транспортный сервер в кэше, чтобы поддерживать его в актуальном состоянии после загрузки
|
||||||
|
*/
|
||||||
|
const updateAttachmentTransportInCache = (dialog: string, message_id : string, attachment: Attachment) => {
|
||||||
|
const dialogCache = getDialogCache(dialog);
|
||||||
|
if(dialogCache == null){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(let i = 0; i < dialogCache.length; i++){
|
||||||
|
if(dialogCache[i].message_id == message_id){
|
||||||
|
for(let j = 0; j < dialogCache[i].attachments.length; j++){
|
||||||
|
if(dialogCache[i].attachments[j].id == attachment.id){
|
||||||
|
dialogCache[i].attachments[j].transport = attachment.transport;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет транспорт в базе после загрузки вложения (нам нужно сохранить транспорт)
|
||||||
|
*/
|
||||||
|
const updateAttachmentTransportInDatabase = async (message_id : string, attachment: Attachment) => {
|
||||||
|
let message = await getQuery(`SELECT attachments FROM messages WHERE message_id = ?`, [message_id]);
|
||||||
|
console.info(message)
|
||||||
|
if(!message){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(message.attachments == '[]'){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let meta : AttachmentMeta[] = JSON.parse(message.attachments);
|
||||||
|
for(let i = 0; i < meta.length; i++){
|
||||||
|
if(meta[i].id == attachment.id){
|
||||||
|
meta[i].transport = attachment.transport;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await runQuery(`UPDATE messages SET attachments = ? WHERE message_id = ?`, [JSON.stringify(meta), message_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет вложение в стейте сообщений
|
||||||
|
*/
|
||||||
|
const updateAttachmentTransportInContext = (message_id: string, attachment : Attachment) => {
|
||||||
|
if(context == null || !context){
|
||||||
|
/**
|
||||||
|
* Если этот диалог сейчас не открыт
|
||||||
|
*/
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.setMessages((prev) => {
|
||||||
|
return prev.map((value) => {
|
||||||
|
if(value.message_id != message_id){
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
for(let i = 0; i < value.attachments.length; i++){
|
||||||
|
if(value.attachments[i].id != attachment.id){
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
value.attachments[i].transport = attachment.transport;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTransportAfterUploading = async (dialog: string, message_id : string, attachment: Attachment) => {
|
||||||
|
updateAttachmentTransportInCache(dialog, message_id, attachment);
|
||||||
|
updateAttachmentTransportInDatabase(message_id, attachment);
|
||||||
|
updateAttachmentTransportInContext(message_id, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновляет временную метку в сообщении, пока вложения отправляются,
|
* Обновляет временную метку в сообщении, пока вложения отправляются,
|
||||||
* потому что если этого не делать, то сообщение может быть помечено как
|
* потому что если этого не делать, то сообщение может быть помечено как
|
||||||
@@ -74,18 +149,6 @@ export function usePrepareAttachment() {
|
|||||||
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
|
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Удаляет старый тег если вложения были подготовлены заново
|
|
||||||
* например при пересылке сообщений
|
|
||||||
*/
|
|
||||||
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
|
|
||||||
if(preview.indexOf("::") == -1){
|
|
||||||
return preview;
|
|
||||||
}
|
|
||||||
let parts = preview.split("::");
|
|
||||||
return parts.slice(1).join("::");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подготавливает вложения для отправки. Подготовка
|
* Подготавливает вложения для отправки. Подготовка
|
||||||
* состоит в загрузке файлов на транспортный сервер, мы не делаем
|
* состоит в загрузке файлов на транспортный сервер, мы не делаем
|
||||||
@@ -93,7 +156,7 @@ export function usePrepareAttachment() {
|
|||||||
* а так же из-за надежности доставки файлов через HTTP
|
* а так же из-за надежности доставки файлов через HTTP
|
||||||
* @param attachments Attachments to prepare for sending
|
* @param attachments Attachments to prepare for sending
|
||||||
*/
|
*/
|
||||||
const prepareAttachmentsToSend = async (message_id: string, dialog: string, password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
|
const prepareAttachmentsToSend = async (message_id: string, dialog: string, password: string, attachments : Attachment[]) : Promise<Attachment[]> => {
|
||||||
if(attachments.length <= 0){
|
if(attachments.length <= 0){
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -103,14 +166,17 @@ export function usePrepareAttachment() {
|
|||||||
const attachment : Attachment = attachments[i];
|
const attachment : Attachment = attachments[i];
|
||||||
if(attachment.type == AttachmentType.CALL){
|
if(attachment.type == AttachmentType.CALL){
|
||||||
/**
|
/**
|
||||||
* Звонок загружать не надо
|
* Звонк загружать не надо, по этому просто отправляем его как есть, там нет blob
|
||||||
*/
|
*/
|
||||||
|
prepared.push(attachment);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if(attachment.type == AttachmentType.MESSAGES){
|
if(attachment.type == AttachmentType.MESSAGES){
|
||||||
let reply : MessageReply[] = JSON.parse(attachment.blob)
|
let reply : MessageReply[] = JSON.parse(attachment.blob);
|
||||||
for(let j = 0; j < reply.length; j++){
|
for(let j = 0; j < reply.length; j++){
|
||||||
reply[j].attachments = await prepareAttachmentsToSend(message_id, dialog, password, reply[j].attachments, true);
|
for(let k = 0; k < reply[j].attachments.length; k++){
|
||||||
|
reply[j].attachments[k].blob = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prepared.push({
|
prepared.push({
|
||||||
...attachment,
|
...attachment,
|
||||||
@@ -131,16 +197,30 @@ export function usePrepareAttachment() {
|
|||||||
const upid = attachment.id;
|
const upid = attachment.id;
|
||||||
info(`Uploading attachment with upid: ${upid}`);
|
info(`Uploading attachment with upid: ${upid}`);
|
||||||
info(`Attachment content length: ${content.length}`);
|
info(`Attachment content length: ${content.length}`);
|
||||||
let tag = await uploadFile(upid, content);
|
let tag = await uploadFile(upid, content).catch(() => {
|
||||||
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
|
error(`Network error while uploading attachment ${upid}`);
|
||||||
if(intervalsRef.current != null){
|
|
||||||
clearInterval(intervalsRef.current);
|
|
||||||
}
|
|
||||||
prepared.push({
|
|
||||||
...attachment,
|
|
||||||
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
|
|
||||||
blob: ""
|
|
||||||
});
|
});
|
||||||
|
if(!tag){
|
||||||
|
/**
|
||||||
|
* При ошибке загрузки по сети
|
||||||
|
*/
|
||||||
|
stopUpdateTimeInUpAttachment();
|
||||||
|
console.info("stop upd")
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
|
||||||
|
stopUpdateTimeInUpAttachment();
|
||||||
|
const preparedAttachment : Attachment = {
|
||||||
|
...attachment,
|
||||||
|
transport: {
|
||||||
|
transport_server: transportServer || "",
|
||||||
|
transport_tag: tag
|
||||||
|
},
|
||||||
|
preview: attachment.preview,
|
||||||
|
blob: ""
|
||||||
|
};
|
||||||
|
await updateTransportAfterUploading(dialog, message_id, preparedAttachment);
|
||||||
|
prepared.push(preparedAttachment);
|
||||||
}
|
}
|
||||||
return prepared;
|
return prepared;
|
||||||
}catch(e){
|
}catch(e){
|
||||||
@@ -148,6 +228,12 @@ export function usePrepareAttachment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stopUpdateTimeInUpAttachment = () => {
|
||||||
|
if(intervalsRef.current != null){
|
||||||
|
clearInterval(intervalsRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prepareAttachmentsToSend
|
prepareAttachmentsToSend
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
const mutedRef = useRef<boolean>(false);
|
const mutedRef = useRef<boolean>(false);
|
||||||
const soundRef = useRef<boolean>(true);
|
const soundRef = useRef<boolean>(true);
|
||||||
const {sendMessage} = useDeattachedSender();
|
const {sendMessage} = useDeattachedSender();
|
||||||
|
const hasRemoteTrackRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Используются для входа в звонок
|
||||||
|
*/
|
||||||
|
const callSessionIdRef = useRef<string>("");
|
||||||
|
const callTokenRef = useRef<string>("");
|
||||||
|
|
||||||
const {playSound, stopSound, stopLoopSound} = useSound();
|
const {playSound, stopSound, stopLoopSound} = useSound();
|
||||||
const {setWindowPriority} = useWindow();
|
const {setWindowPriority} = useWindow();
|
||||||
@@ -169,7 +176,6 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
* Другая сторона отправила нам ICE кандидата для установления WebRTC соединения
|
* Другая сторона отправила нам ICE кандидата для установления WebRTC соединения
|
||||||
*/
|
*/
|
||||||
const candidate = JSON.parse(packet.getSdpOrCandidate());
|
const candidate = JSON.parse(packet.getSdpOrCandidate());
|
||||||
console.info(candidate);
|
|
||||||
if(peerConnectionRef.current?.remoteDescription == null){
|
if(peerConnectionRef.current?.remoteDescription == null){
|
||||||
/**
|
/**
|
||||||
* Удаленное описание еще не установлено, буферизуем кандидата, чтобы добавить его после установки удаленного описания
|
* Удаленное описание еще не установлено, буферизуем кандидата, чтобы добавить его после установки удаленного описания
|
||||||
@@ -210,16 +216,14 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
openCallsModal("The connection with the user was lost. The call has ended.")
|
openCallsModal("The connection with the user was lost. The call has ended.")
|
||||||
end();
|
end();
|
||||||
}
|
}
|
||||||
if(activeCall){
|
if(signalType == SignalType.RINGING_TIMEOUT) {
|
||||||
/**
|
/**
|
||||||
* У нас уже есть активный звонок, игнорируем все сигналы, кроме сигналов от текущего звонка
|
* Другой стороне был отправлен сигнал звонка, но она не ответила на него в течении определенного времени
|
||||||
*/
|
*/
|
||||||
if(packet.getSrc() != activeCall && packet.getSrc() != publicKey){
|
openCallsModal("The user did not answer the call in time. Please try again later.");
|
||||||
console.info("Received signal from " + packet.getSrc() + " but active call is with " + activeCall + ", ignoring");
|
end();
|
||||||
info("Received signal for another call, ignoring");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if(signalType == SignalType.END_CALL){
|
if(signalType == SignalType.END_CALL){
|
||||||
/**
|
/**
|
||||||
* Сбросили звонок
|
* Сбросили звонок
|
||||||
@@ -231,52 +235,24 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
/**
|
/**
|
||||||
* Нам поступает звонок
|
* Нам поступает звонок
|
||||||
*/
|
*/
|
||||||
|
if(callState != CallState.ENDED){
|
||||||
|
/**
|
||||||
|
* У нас уже есть активный звонок, отправляем сигнал другой стороне, что линия занята
|
||||||
|
*/
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callSessionIdRef.current = packet.getCallId();
|
||||||
|
callTokenRef.current = packet.getJoinToken();
|
||||||
setWindowPriority(true);
|
setWindowPriority(true);
|
||||||
playSound("ringtone.mp3", true);
|
playSound("ringtone.mp3", true);
|
||||||
setActiveCall(packet.getSrc());
|
setActiveCall(packet.getSrc());
|
||||||
setCallState(CallState.INCOMING);
|
setCallState(CallState.INCOMING);
|
||||||
setShowCallView(true);
|
setShowCallView(true);
|
||||||
}
|
}
|
||||||
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLER){
|
if(signalType == SignalType.KEY_EXCHANGE){
|
||||||
console.info("EXCHANGE SIGNAL RECEIVED, CALLER ROLE");
|
|
||||||
/**
|
|
||||||
* Другая сторона сгенерировала ключи для сессии и отправила нам публичную часть,
|
|
||||||
* теперь мы можем создать общую секретную сессию для шифрования звонка
|
|
||||||
*/
|
|
||||||
const sharedPublic = packet.getSharedPublic();
|
|
||||||
if(!sharedPublic){
|
|
||||||
info("Received key exchange signal without shared public key");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sessionKeys = generateSessionKeys();
|
|
||||||
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
|
||||||
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
|
||||||
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
|
||||||
/**
|
|
||||||
* Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию
|
|
||||||
*/
|
|
||||||
const signalPacket = new PacketSignalPeer();
|
|
||||||
signalPacket.setSrc(publicKey);
|
|
||||||
signalPacket.setDst(packet.getSrc());
|
|
||||||
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
|
|
||||||
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
|
|
||||||
send(signalPacket);
|
|
||||||
setCallState(CallState.WEB_RTC_EXCHANGE);
|
|
||||||
/**
|
|
||||||
* Создаем комнату на сервере SFU, комнату создает звонящий
|
|
||||||
*/
|
|
||||||
let webRtcSignal = new PacketSignalPeer();
|
|
||||||
webRtcSignal.setSignalType(SignalType.CREATE_ROOM);
|
|
||||||
webRtcSignal.setSrc(publicKey);
|
|
||||||
webRtcSignal.setDst(packet.getSrc());
|
|
||||||
send(webRtcSignal);
|
|
||||||
}
|
|
||||||
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLEE){
|
|
||||||
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
|
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
|
||||||
/**
|
/**
|
||||||
* Мы отправили свою публичную часть ключа другой стороне,
|
* Другая сторона отправила нам ключи, теперь отправляем ей свои для генерации общего секрета
|
||||||
* теперь мы получили ее публичную часть и можем создать общую
|
|
||||||
* секретную сессию для шифрования звонка
|
|
||||||
*/
|
*/
|
||||||
const sharedPublic = packet.getSharedPublic();
|
const sharedPublic = packet.getSharedPublic();
|
||||||
if(!sharedPublic){
|
if(!sharedPublic){
|
||||||
@@ -287,17 +263,53 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
info("Received key exchange signal but session keys are not generated");
|
info("Received key exchange signal but session keys are not generated");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
||||||
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
||||||
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
||||||
setCallState(CallState.WEB_RTC_EXCHANGE);
|
setCallState(CallState.WEB_RTC_EXCHANGE);
|
||||||
}
|
|
||||||
if(signalType == SignalType.CREATE_ROOM) {
|
if(roleRef.current == CallRole.CALLER){
|
||||||
/**
|
/**
|
||||||
* Создана комната для обмена WebRTC потоками
|
* Вызывающий уже отправил ключ, сессия сгенерирована, сообщаем серверу что звонок активен
|
||||||
*/
|
*/
|
||||||
roomIdRef.current = packet.getRoomId();
|
const activeSignal = new PacketSignalPeer();
|
||||||
info("WebRTC room created with id: " + packet.getRoomId());
|
activeSignal.setSrc(publicKey);
|
||||||
|
activeSignal.setDst(activeCall);
|
||||||
|
activeSignal.setSignalType(SignalType.ACTIVE);
|
||||||
|
send(activeSignal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signalPacket = new PacketSignalPeer();
|
||||||
|
signalPacket.setSrc(publicKey);
|
||||||
|
signalPacket.setDst(activeCall);
|
||||||
|
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
|
||||||
|
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
|
||||||
|
send(signalPacket);
|
||||||
|
}
|
||||||
|
if(signalType == SignalType.ACCEPT){
|
||||||
|
/**
|
||||||
|
* Другая сторона приняла наш звонок, комната на SFU создалась, нужно сгенерировать ключи
|
||||||
|
*/
|
||||||
|
const keys = generateSessionKeys();
|
||||||
|
const signalPacket = new PacketSignalPeer();
|
||||||
|
signalPacket.setSrc(publicKey);
|
||||||
|
signalPacket.setDst(activeCall);
|
||||||
|
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
|
||||||
|
signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex'));
|
||||||
|
send(signalPacket);
|
||||||
|
}
|
||||||
|
if(signalType == SignalType.ACTIVE) {
|
||||||
|
if(!sessionKeys){
|
||||||
|
/**
|
||||||
|
* Сервер может отправить CREATE_ROOM сигнал, даже если мы приняли звонок на другом устройстве, по этому проверяем,
|
||||||
|
* на этом ли устройстве звонок принят посредством проверки наличия сгенерированных ключей шифрования
|
||||||
|
*/
|
||||||
|
stopLoopSound();
|
||||||
|
stopSound();
|
||||||
|
end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
|
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
|
||||||
*/
|
*/
|
||||||
@@ -323,7 +335,11 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
peerConnectionRef.current.onconnectionstatechange = () => {
|
peerConnectionRef.current.onconnectionstatechange = () => {
|
||||||
console.info("Peer connection state changed: " + peerConnectionRef.current?.connectionState);
|
console.info("Peer connection state changed: " + peerConnectionRef.current?.connectionState);
|
||||||
if(peerConnectionRef.current?.connectionState == "connected"){
|
if(peerConnectionRef.current?.connectionState == "connected"){
|
||||||
setCallState(CallState.ACTIVE);
|
/**
|
||||||
|
* WebRTC соединение установлено, звонок активен, останавливаем все остальные звуки
|
||||||
|
* системы
|
||||||
|
*/
|
||||||
|
tryActivateCall();
|
||||||
info("WebRTC connection established, call is active");
|
info("WebRTC connection established, call is active");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,7 +354,8 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
* При получении медиа-трека с другой стороны
|
* При получении медиа-трека с другой стороны
|
||||||
*/
|
*/
|
||||||
if(remoteAudioRef.current && event.streams[0]){
|
if(remoteAudioRef.current && event.streams[0]){
|
||||||
console.info(event.streams);
|
hasRemoteTrackRef.current = true;
|
||||||
|
tryActivateCall();
|
||||||
remoteAudioRef.current.srcObject = event.streams[0];
|
remoteAudioRef.current.srcObject = event.streams[0];
|
||||||
remoteAudioRef.current.muted = !soundRef.current;
|
remoteAudioRef.current.muted = !soundRef.current;
|
||||||
void remoteAudioRef.current.play().catch((e) => {
|
void remoteAudioRef.current.play().catch((e) => {
|
||||||
@@ -375,6 +392,15 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
}
|
}
|
||||||
}, [activeCall, sessionKeys, duration]);
|
}, [activeCall, sessionKeys, duration]);
|
||||||
|
|
||||||
|
const tryActivateCall = () => {
|
||||||
|
if(hasRemoteTrackRef.current && peerConnectionRef.current?.connectionState == "connected"){
|
||||||
|
stopLoopSound();
|
||||||
|
stopSound();
|
||||||
|
setCallState(CallState.ACTIVE);
|
||||||
|
info("Call is now active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openCallsModal = (text : string) => {
|
const openCallsModal = (text : string) => {
|
||||||
modals.open({
|
modals.open({
|
||||||
centered: true,
|
centered: true,
|
||||||
@@ -384,7 +410,9 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
<Flex align={'center'} justify={'flex-end'}>
|
<Flex align={'center'} justify={'flex-end'}>
|
||||||
<Button color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
|
<Button style={{
|
||||||
|
outline: 'none'
|
||||||
|
}} color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -426,14 +454,20 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
const packetSignal = new PacketSignalPeer();
|
const packetSignal = new PacketSignalPeer();
|
||||||
packetSignal.setSrc(publicKey);
|
packetSignal.setSrc(publicKey);
|
||||||
packetSignal.setDst(activeCall);
|
packetSignal.setDst(activeCall);
|
||||||
|
packetSignal.setCallId(callSessionIdRef.current);
|
||||||
|
packetSignal.setJoinToken(callTokenRef.current);
|
||||||
packetSignal.setSignalType(SignalType.END_CALL);
|
packetSignal.setSignalType(SignalType.END_CALL);
|
||||||
send(packetSignal);
|
send(packetSignal);
|
||||||
end();
|
end();
|
||||||
}
|
}
|
||||||
|
|
||||||
const end = () => {
|
const end = () => {
|
||||||
stopLoopSound();
|
if(callState == CallState.ACTIVE){
|
||||||
stopSound();
|
/**
|
||||||
|
* Только если звонок был активен воспроизводим звуки
|
||||||
|
*/
|
||||||
|
playSound("end_call.mp3");
|
||||||
|
}
|
||||||
if (remoteAudioRef.current) {
|
if (remoteAudioRef.current) {
|
||||||
remoteAudioRef.current.pause();
|
remoteAudioRef.current.pause();
|
||||||
remoteAudioRef.current.srcObject = null;
|
remoteAudioRef.current.srcObject = null;
|
||||||
@@ -442,7 +476,6 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
setDuration(0);
|
setDuration(0);
|
||||||
durationIntervalRef.current && clearInterval(durationIntervalRef.current);
|
durationIntervalRef.current && clearInterval(durationIntervalRef.current);
|
||||||
setWindowPriority(false);
|
setWindowPriority(false);
|
||||||
playSound("end_call.mp3");
|
|
||||||
peerConnectionRef.current?.close();
|
peerConnectionRef.current?.close();
|
||||||
peerConnectionRef.current = null;
|
peerConnectionRef.current = null;
|
||||||
roomIdRef.current = "";
|
roomIdRef.current = "";
|
||||||
@@ -455,6 +488,8 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
setDuration(0);
|
setDuration(0);
|
||||||
setMutedState(false);
|
setMutedState(false);
|
||||||
setSoundState(true);
|
setSoundState(true);
|
||||||
|
stopLoopSound();
|
||||||
|
stopSound();
|
||||||
roleRef.current = null;
|
roleRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,21 +497,22 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
* Отправляет сообщение в диалог с звонящим с информацией о звонке
|
* Отправляет сообщение в диалог с звонящим с информацией о звонке
|
||||||
*/
|
*/
|
||||||
const generateCallAttachment = () => {
|
const generateCallAttachment = () => {
|
||||||
let preview = "";
|
if(roleRef.current != CallRole.CALLER){
|
||||||
if(roleRef.current == CallRole.CALLER){
|
/**
|
||||||
preview += "1::";
|
* Только звонящий отправляет информацию о звонке в виде вложения, чтобы ее можно было отобразить в UI диалога, например длительность звонка
|
||||||
|
*/
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if(roleRef.current == CallRole.CALLEE){
|
|
||||||
preview += "0::";
|
|
||||||
}
|
|
||||||
preview += duration.toString();
|
|
||||||
|
|
||||||
sendMessage(activeCall, "", [{
|
sendMessage(activeCall, "", [{
|
||||||
id: generateRandomKey(16),
|
id: generateRandomKey(16),
|
||||||
preview: preview,
|
preview: duration.toString(),
|
||||||
type: AttachmentType.CALL,
|
type: AttachmentType.CALL,
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
},
|
||||||
blob: ""
|
blob: ""
|
||||||
}], false);
|
}], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accept = () => {
|
const accept = () => {
|
||||||
@@ -490,15 +526,22 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
stopLoopSound();
|
stopLoopSound();
|
||||||
stopSound();
|
stopSound();
|
||||||
/**
|
/**
|
||||||
* Звонок принят, генерируем ключи для сессии и отправляем их другой стороне для установления защищенного канала связи
|
* Звонок принят, генерируем свой ключ для будующего обмена
|
||||||
|
*/
|
||||||
|
generateSessionKeys();
|
||||||
|
/**
|
||||||
|
* Отправляем сигнал что звонок принят другой стороне, чтобы она могла начать обмен ключами и установку соединения
|
||||||
*/
|
*/
|
||||||
const keys = generateSessionKeys();
|
|
||||||
const signalPacket = new PacketSignalPeer();
|
const signalPacket = new PacketSignalPeer();
|
||||||
signalPacket.setSrc(publicKey);
|
signalPacket.setSrc(publicKey);
|
||||||
signalPacket.setDst(activeCall);
|
signalPacket.setDst(activeCall);
|
||||||
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
|
signalPacket.setCallId(callSessionIdRef.current);
|
||||||
signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex'));
|
signalPacket.setJoinToken(callTokenRef.current);
|
||||||
|
signalPacket.setSignalType(SignalType.ACCEPT);
|
||||||
send(signalPacket);
|
send(signalPacket);
|
||||||
|
/**
|
||||||
|
* Устанавливаем состояние звонка и стадию обмена ключами
|
||||||
|
*/
|
||||||
setCallState(CallState.KEY_EXCHANGE);
|
setCallState(CallState.KEY_EXCHANGE);
|
||||||
roleRef.current = CallRole.CALLEE;
|
roleRef.current = CallRole.CALLEE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto';
|
import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto';
|
||||||
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
|
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
|
||||||
import { createContext, useEffect, useRef, useState } from 'react';
|
import { createContext, useEffect, useRef, useState } from 'react';
|
||||||
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
|
import { Attachment, AttachmentTransport, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
|
||||||
import { usePrivatePlain } from '../AccountProvider/usePrivatePlain';
|
import { usePrivatePlain } from '../AccountProvider/usePrivatePlain';
|
||||||
import { usePublicKey } from '../AccountProvider/usePublicKey';
|
import { usePublicKey } from '../AccountProvider/usePublicKey';
|
||||||
import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read';
|
import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read';
|
||||||
@@ -46,6 +46,7 @@ export interface AttachmentMeta {
|
|||||||
id: string;
|
id: string;
|
||||||
type: AttachmentType;
|
type: AttachmentType;
|
||||||
preview: string;
|
preview: string;
|
||||||
|
transport: AttachmentTransport;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -214,26 +215,40 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
readUpdated = true;
|
readUpdated = true;
|
||||||
}
|
}
|
||||||
let decryptKey = '';
|
let decryptKey = '';
|
||||||
|
if(message.from_me && message.chacha_key != "" && !message.chacha_key.startsWith("sync:")){
|
||||||
|
/**
|
||||||
|
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key
|
||||||
|
*/
|
||||||
|
try{
|
||||||
|
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key), 'binary').toString('hex');
|
||||||
|
}catch(e) {
|
||||||
|
decryptKey = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
if(message.from_me && message.chacha_key != "" && message.chacha_key.startsWith("sync:")){
|
if(message.from_me && message.chacha_key != "" && message.chacha_key.startsWith("sync:")){
|
||||||
/**
|
/**
|
||||||
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это
|
* Если это сообщение от нас, то проверяем, есть ли внутри chacha_key, если есть, значит это
|
||||||
* сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет,
|
* сообщение пришло нам в результате синхронизации и его нужно расшифровать, если chacha_key нет,
|
||||||
* значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (plain_message)
|
* значит сообщение отправлено с нашего устройства, и зашифровано на стороне отправки (plain_message)
|
||||||
*/
|
*/
|
||||||
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('utf-8');
|
decryptKey = Buffer.from(await decodeWithPassword(privatePlain, message.chacha_key.replace("sync:", "")), 'binary').toString('hex');
|
||||||
}
|
}
|
||||||
if(hasGroup(props.dialog)){
|
if(hasGroup(props.dialog)){
|
||||||
/**
|
/**
|
||||||
* Если это групповое сообщение, то получаем ключ группы
|
* Если это групповое сообщение, то получаем ключ группы
|
||||||
*/
|
*/
|
||||||
decryptKey = await getGroupKey(props.dialog);
|
decryptKey = await getGroupKey(props.dialog);
|
||||||
|
/**
|
||||||
|
* Приводим к HEX так как этого требует формат расшифровки вложений в приложении
|
||||||
|
*/
|
||||||
|
decryptKey = Buffer.from(decryptKey).toString('hex');
|
||||||
}
|
}
|
||||||
if(!message.from_me && !hasGroup(props.dialog)){
|
if(!message.from_me && !hasGroup(props.dialog)){
|
||||||
/**
|
/**
|
||||||
* Если сообщение не от меня и не групповое,
|
* Если сообщение не от меня и не групповое,
|
||||||
* расшифровываем ключ чачи своим приватным ключом
|
* расшифровываем ключ чачи своим приватным ключом
|
||||||
*/
|
*/
|
||||||
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
|
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
|
||||||
}
|
}
|
||||||
finalMessages.push({
|
finalMessages.push({
|
||||||
from_public_key: message.from_public_key,
|
from_public_key: message.from_public_key,
|
||||||
@@ -469,10 +484,8 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
attachments.push({
|
attachments.push({
|
||||||
id: attachment.id,
|
...attachment,
|
||||||
preview: attachment.preview,
|
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
|
||||||
type: attachment.type,
|
|
||||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +495,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: 0, //сообщение прочитано
|
readed: 0, //сообщение прочитано
|
||||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||||
from_me: 1, //сообщение от нас
|
from_me: 1, //сообщение от нас
|
||||||
plain_message: (decryptedContent as string),
|
plain_message: (decryptedContent as string),
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -549,9 +562,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
attachments.push({
|
attachments.push({
|
||||||
id: attachment.id,
|
...attachment,
|
||||||
preview: attachment.preview,
|
|
||||||
type: attachment.type,
|
|
||||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -562,7 +573,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: 0,
|
readed: 0,
|
||||||
chacha_key: groupKey,
|
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||||
from_me: 1,
|
from_me: 1,
|
||||||
plain_message: decryptedContent,
|
plain_message: decryptedContent,
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -627,20 +638,18 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
attachments.push({
|
attachments.push({
|
||||||
id: attachment.id,
|
...attachment,
|
||||||
preview: attachment.preview,
|
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob) : ""
|
||||||
type: attachment.type,
|
|
||||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
console.info(attachments);
|
||||||
const newMessage : Message = {
|
const newMessage : Message = {
|
||||||
from_public_key: fromPublicKey,
|
from_public_key: fromPublicKey,
|
||||||
to_public_key: toPublicKey,
|
to_public_key: toPublicKey,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: (decryptedContent as string),
|
plain_message: (decryptedContent as string),
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -707,9 +716,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
for(let i = 0; i < packet.getAttachments().length; i++) {
|
for(let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
attachments.push({
|
attachments.push({
|
||||||
id: attachment.id,
|
...attachment,
|
||||||
preview: attachment.preview,
|
|
||||||
type: attachment.type,
|
|
||||||
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -720,7 +727,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: groupKey,
|
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: decryptedContent,
|
plain_message: decryptedContent,
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -794,7 +801,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
* Если сообщение не от меня и не групповое,
|
* Если сообщение не от меня и не групповое,
|
||||||
* расшифровываем ключ чачи своим приватным ключом
|
* расшифровываем ключ чачи своим приватным ключом
|
||||||
*/
|
*/
|
||||||
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
|
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
|
||||||
}
|
}
|
||||||
finalMessages.push({
|
finalMessages.push({
|
||||||
from_public_key: message.from_public_key,
|
from_public_key: message.from_public_key,
|
||||||
@@ -879,7 +886,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
* Если сообщение не от меня и не групповое,
|
* Если сообщение не от меня и не групповое,
|
||||||
* расшифровываем ключ чачи своим приватным ключом
|
* расшифровываем ключ чачи своим приватным ключом
|
||||||
*/
|
*/
|
||||||
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
|
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('hex');
|
||||||
}
|
}
|
||||||
finalMessages.push({
|
finalMessages.push({
|
||||||
from_public_key: message.from_public_key,
|
from_public_key: message.from_public_key,
|
||||||
@@ -964,7 +971,8 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
id: meta.id,
|
id: meta.id,
|
||||||
blob: blob,
|
blob: blob,
|
||||||
type: meta.type,
|
type: meta.type,
|
||||||
preview: meta.preview
|
preview: meta.preview,
|
||||||
|
transport: meta.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return attachments;
|
return attachments;
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ export function useDeattachedSender() {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
if(attachment.type == AttachmentType.FILE){
|
if(attachment.type == AttachmentType.FILE){
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +133,7 @@ export function useDeattachedSender() {
|
|||||||
|| publicKey == dialog) {
|
|| publicKey == dialog) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts);
|
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
|
||||||
if(attachemnts.length <= 0 && message.trim() == ""){
|
if(attachemnts.length <= 0 && message.trim() == ""){
|
||||||
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
||||||
updateDialog(dialog);
|
updateDialog(dialog);
|
||||||
|
|||||||
@@ -96,14 +96,13 @@ export function useDialog() : {
|
|||||||
* же сообщений (смотреть problem_sync.md)
|
* же сообщений (смотреть problem_sync.md)
|
||||||
*/
|
*/
|
||||||
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
|
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
|
||||||
|
|
||||||
setMessages((prev : Message[]) => ([...prev, {
|
setMessages((prev : Message[]) => ([...prev, {
|
||||||
from_public_key: publicKey,
|
from_public_key: publicKey,
|
||||||
to_public_key: dialog,
|
to_public_key: dialog,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
readed: publicKey == dialog ? 1 : 0,
|
readed: publicKey == dialog ? 1 : 0,
|
||||||
chacha_key: "",
|
chacha_key: key.toString('hex'),
|
||||||
from_me: 1,
|
from_me: 1,
|
||||||
plain_message: message,
|
plain_message: message,
|
||||||
delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
|
delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
|
||||||
@@ -118,7 +117,8 @@ export function useDialog() : {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
if(attachment.type == AttachmentType.FILE){
|
if(attachment.type == AttachmentType.FILE){
|
||||||
/**
|
/**
|
||||||
@@ -135,7 +135,7 @@ export function useDialog() : {
|
|||||||
await runQuery(`
|
await runQuery(`
|
||||||
INSERT INTO messages
|
INSERT INTO messages
|
||||||
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
|
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, aesChachaKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
|
||||||
protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING
|
protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING
|
||||||
), JSON.stringify(attachmentsMeta)]);
|
), JSON.stringify(attachmentsMeta)]);
|
||||||
updateDialog(dialog);
|
updateDialog(dialog);
|
||||||
@@ -145,10 +145,13 @@ export function useDialog() : {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
|
|
||||||
console.info("Sending key for message ", key.toString('hex'));
|
console.info("Sending key for message ", key.toString('hex'));
|
||||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts);
|
console.info(attachemnts);
|
||||||
if(attachemnts.length <= 0 && message.trim() == ""){
|
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
|
||||||
|
if(preparedToNetworkSendAttachements.length < attachemnts.length){
|
||||||
|
/**
|
||||||
|
* Если не удалось нормально загрузить все вложения - тогда не отправляем сообщение
|
||||||
|
*/
|
||||||
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
||||||
updateDialog(dialog);
|
updateDialog(dialog);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
|||||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||||
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
|
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from "@/app/workers/crypto/crypto";
|
||||||
import { DeliveredMessageState, Message } from "./DialogProvider";
|
import { AttachmentMeta, DeliveredMessageState, Message } from "./DialogProvider";
|
||||||
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
|
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
|
||||||
import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery";
|
import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery";
|
||||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||||
@@ -104,7 +104,7 @@ export function useDialogFiber() {
|
|||||||
decryptedContent = '';
|
decryptedContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let attachmentsMeta: any[] = [];
|
let attachmentsMeta: AttachmentMeta[] = [];
|
||||||
let messageAttachments: Attachment[] = [];
|
let messageAttachments: Attachment[] = [];
|
||||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
@@ -129,7 +129,8 @@ export function useDialogFiber() {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +140,7 @@ export function useDialogFiber() {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: groupKey,
|
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: decryptedContent,
|
plain_message: decryptedContent,
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -261,7 +262,7 @@ export function useDialogFiber() {
|
|||||||
const nonce = chachaDecryptedKey.slice(32);
|
const nonce = chachaDecryptedKey.slice(32);
|
||||||
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
||||||
|
|
||||||
let attachmentsMeta: any[] = [];
|
let attachmentsMeta: AttachmentMeta[] = [];
|
||||||
let messageAttachments: Attachment[] = [];
|
let messageAttachments: Attachment[] = [];
|
||||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
@@ -277,7 +278,7 @@ export function useDialogFiber() {
|
|||||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
* Этот тип вложения приходит сразу в blob и не нуждается
|
||||||
* в последующем скачивании
|
* в последующем скачивании
|
||||||
*/
|
*/
|
||||||
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
|
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob);
|
||||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||||
@@ -286,7 +287,8 @@ export function useDialogFiber() {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +298,7 @@ export function useDialogFiber() {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: (decryptedContent as string),
|
plain_message: (decryptedContent as string),
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface MessageReply {
|
|||||||
message: string;
|
message: string;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
message_id: string;
|
message_id: string;
|
||||||
|
chacha_key_plain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReplyMessages() {
|
export function useReplyMessages() {
|
||||||
@@ -53,7 +54,6 @@ export function useReplyMessages() {
|
|||||||
}
|
}
|
||||||
replyMessages.messages.push(message);
|
replyMessages.messages.push(message);
|
||||||
const sortedByTime = replyMessages.messages.sort((a, b) => a.timestamp - b.timestamp);
|
const sortedByTime = replyMessages.messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
setReplyMessages({
|
setReplyMessages({
|
||||||
publicKey: dialog,
|
publicKey: dialog,
|
||||||
messages: sortedByTime
|
messages: sortedByTime
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useGroupInviteStatus } from "./useGroupInviteStatus";
|
|||||||
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
|
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||||
import { useUpdateSyncTime } from "./useUpdateSyncTime";
|
import { useUpdateSyncTime } from "./useUpdateSyncTime";
|
||||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||||
import { DeliveredMessageState, Message } from "./DialogProvider";
|
import { AttachmentMeta, DeliveredMessageState, Message } from "./DialogProvider";
|
||||||
import { MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from "@/app/constants";
|
import { MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from "@/app/constants";
|
||||||
import { useMemory } from "../MemoryProvider/useMemory";
|
import { useMemory } from "../MemoryProvider/useMemory";
|
||||||
import { useDialogsCache } from "./useDialogsCache";
|
import { useDialogsCache } from "./useDialogsCache";
|
||||||
@@ -165,7 +165,7 @@ export function useSynchronize() {
|
|||||||
const nonce = chachaDecryptedKey.slice(32);
|
const nonce = chachaDecryptedKey.slice(32);
|
||||||
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
|
||||||
await updateSyncTime(timestamp);
|
await updateSyncTime(timestamp);
|
||||||
let attachmentsMeta: any[] = [];
|
let attachmentsMeta: AttachmentMeta[] = [];
|
||||||
let messageAttachments: Attachment[] = [];
|
let messageAttachments: Attachment[] = [];
|
||||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
@@ -181,7 +181,7 @@ export function useSynchronize() {
|
|||||||
* Этот тип вложения приходит сразу в blob и не нуждается
|
* Этот тип вложения приходит сразу в blob и не нуждается
|
||||||
* в последующем скачивании
|
* в последующем скачивании
|
||||||
*/
|
*/
|
||||||
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
|
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('hex'), attachment.blob);
|
||||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||||
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
|
||||||
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
messageAttachments[nextLength - 1].blob = decryptedBlob;
|
||||||
@@ -190,7 +190,8 @@ export function useSynchronize() {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +201,7 @@ export function useSynchronize() {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: 1, //сообщение прочитано
|
readed: 1, //сообщение прочитано
|
||||||
chacha_key: chachaDecryptedKey.toString('utf-8'),
|
chacha_key: chachaDecryptedKey.toString('hex'),
|
||||||
from_me: 1, //сообщение от нас
|
from_me: 1, //сообщение от нас
|
||||||
plain_message: (decryptedContent as string),
|
plain_message: (decryptedContent as string),
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -347,7 +348,7 @@ export function useSynchronize() {
|
|||||||
decryptedContent = '';
|
decryptedContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let attachmentsMeta: any[] = [];
|
let attachmentsMeta: AttachmentMeta[] = [];
|
||||||
let messageAttachments: Attachment[] = [];
|
let messageAttachments: Attachment[] = [];
|
||||||
for (let i = 0; i < packet.getAttachments().length; i++) {
|
for (let i = 0; i < packet.getAttachments().length; i++) {
|
||||||
const attachment = packet.getAttachments()[i];
|
const attachment = packet.getAttachments()[i];
|
||||||
@@ -372,7 +373,8 @@ export function useSynchronize() {
|
|||||||
attachmentsMeta.push({
|
attachmentsMeta.push({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
preview: attachment.preview
|
preview: attachment.preview,
|
||||||
|
transport: attachment.transport
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
300
app/providers/PlayerProvider/PlayerProvider.tsx
Normal file
300
app/providers/PlayerProvider/PlayerProvider.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { createContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface PlayerContextValue {
|
||||||
|
playAudio: (
|
||||||
|
artist: string,
|
||||||
|
title: string,
|
||||||
|
audio: string | Blob | File,
|
||||||
|
messageId?: string | null
|
||||||
|
) => void;
|
||||||
|
playing: boolean;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
setDuration: (duration: number) => void;
|
||||||
|
duration: number;
|
||||||
|
totalDuration: number;
|
||||||
|
currentMessageId: string | null;
|
||||||
|
lastMessageId: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerContext = createContext<PlayerContextValue | null>(null);
|
||||||
|
|
||||||
|
interface PlayerProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerProvider(props: PlayerProviderProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
const objectUrlRef = useRef<string | null>(null);
|
||||||
|
const rafTimeUpdateRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const isLoadingRef = useRef(false);
|
||||||
|
const isSeekingRef = useRef(false);
|
||||||
|
|
||||||
|
const durationRef = useRef(0);
|
||||||
|
const totalDurationRef = useRef(0);
|
||||||
|
|
||||||
|
const isPlayingRef = useRef(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [duration, setDurationState] = useState(0);
|
||||||
|
const [totalDuration, setTotalDuration] = useState(0);
|
||||||
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [currentMessageId, setCurrentMessageId] = useState<string | null>(null);
|
||||||
|
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const commitPlaying = (next: boolean) => {
|
||||||
|
if (isPlayingRef.current === next) return;
|
||||||
|
isPlayingRef.current = next;
|
||||||
|
setIsPlaying(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitDuration = (next: number) => {
|
||||||
|
const safe = Number.isFinite(next) && next >= 0 ? next : 0;
|
||||||
|
if (Math.abs(safe - durationRef.current) < 0.033) return;
|
||||||
|
durationRef.current = safe;
|
||||||
|
setDurationState(safe);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitTotalDuration = (next: number) => {
|
||||||
|
const safe = Number.isFinite(next) && next > 0 ? next : 0;
|
||||||
|
if (Math.abs(safe - totalDurationRef.current) < 0.05) return;
|
||||||
|
totalDurationRef.current = safe;
|
||||||
|
setTotalDuration(safe);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeMediaError = (err: MediaError | null) => {
|
||||||
|
if (!err) return "Unknown media error";
|
||||||
|
switch (err.code) {
|
||||||
|
case MediaError.MEDIA_ERR_ABORTED:
|
||||||
|
return "Playback aborted";
|
||||||
|
case MediaError.MEDIA_ERR_NETWORK:
|
||||||
|
return "Network error while loading audio";
|
||||||
|
case MediaError.MEDIA_ERR_DECODE:
|
||||||
|
return "Audio decode error";
|
||||||
|
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||||
|
return "Audio source is not supported";
|
||||||
|
default:
|
||||||
|
return `Unknown media error (${err.code})`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const onPlay = () => {
|
||||||
|
if (isLoadingRef.current) return;
|
||||||
|
commitPlaying(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPause = () => {
|
||||||
|
if (isLoadingRef.current) return;
|
||||||
|
commitPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnded = () => {
|
||||||
|
commitPlaying(false);
|
||||||
|
durationRef.current = 0;
|
||||||
|
setDurationState(0);
|
||||||
|
setCurrentMessageId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
if (isLoadingRef.current) return;
|
||||||
|
if (isSeekingRef.current) return;
|
||||||
|
if (rafTimeUpdateRef.current != null) return;
|
||||||
|
|
||||||
|
rafTimeUpdateRef.current = requestAnimationFrame(() => {
|
||||||
|
rafTimeUpdateRef.current = null;
|
||||||
|
if (!isLoadingRef.current && !isSeekingRef.current) {
|
||||||
|
commitDuration(audio.currentTime || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => commitTotalDuration(audio.duration);
|
||||||
|
const onDurationChange = () => commitTotalDuration(audio.duration);
|
||||||
|
|
||||||
|
const onSeeked = () => {
|
||||||
|
if (isSeekingRef.current) {
|
||||||
|
isSeekingRef.current = false;
|
||||||
|
if (!isLoadingRef.current) commitDuration(audio.currentTime || 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isLoadingRef.current) return;
|
||||||
|
commitDuration(audio.currentTime || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCanPlay = () => {
|
||||||
|
if (isLoadingRef.current) isLoadingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (_e: Event) => {
|
||||||
|
const message = decodeMediaError(audio.error);
|
||||||
|
setLastError(message);
|
||||||
|
|
||||||
|
console.error("Audio playback error", {
|
||||||
|
message,
|
||||||
|
mediaError: audio.error,
|
||||||
|
currentSrc: audio.currentSrc,
|
||||||
|
readyState: audio.readyState,
|
||||||
|
networkState: audio.networkState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener("play", onPlay);
|
||||||
|
audio.addEventListener("pause", onPause);
|
||||||
|
audio.addEventListener("ended", onEnded);
|
||||||
|
audio.addEventListener("timeupdate", onTimeUpdate);
|
||||||
|
audio.addEventListener("loadedmetadata", onLoadedMetadata);
|
||||||
|
audio.addEventListener("durationchange", onDurationChange);
|
||||||
|
audio.addEventListener("seeked", onSeeked);
|
||||||
|
audio.addEventListener("canplay", onCanPlay);
|
||||||
|
audio.addEventListener("error", onError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("play", onPlay);
|
||||||
|
audio.removeEventListener("pause", onPause);
|
||||||
|
audio.removeEventListener("ended", onEnded);
|
||||||
|
audio.removeEventListener("timeupdate", onTimeUpdate);
|
||||||
|
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||||
|
audio.removeEventListener("durationchange", onDurationChange);
|
||||||
|
audio.removeEventListener("seeked", onSeeked);
|
||||||
|
audio.removeEventListener("canplay", onCanPlay);
|
||||||
|
audio.removeEventListener("error", onError);
|
||||||
|
|
||||||
|
if (rafTimeUpdateRef.current != null) {
|
||||||
|
cancelAnimationFrame(rafTimeUpdateRef.current);
|
||||||
|
rafTimeUpdateRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
objectUrlRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playAudio = (
|
||||||
|
artist: string,
|
||||||
|
title: string,
|
||||||
|
audio: string | Blob | File,
|
||||||
|
messageId?: string | null
|
||||||
|
) => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// чтобы не было warning о неиспользуемых args при строгих правилах
|
||||||
|
void artist;
|
||||||
|
void title;
|
||||||
|
|
||||||
|
setLastError(null);
|
||||||
|
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
objectUrlRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioSrc = typeof audio === "string" ? audio : URL.createObjectURL(audio);
|
||||||
|
if (typeof audio !== "string") {
|
||||||
|
objectUrlRef.current = audioSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingRef.current = true;
|
||||||
|
isSeekingRef.current = false;
|
||||||
|
|
||||||
|
el.src = audioSrc;
|
||||||
|
durationRef.current = 0;
|
||||||
|
|
||||||
|
const msgId = messageId ?? null;
|
||||||
|
setCurrentMessageId(msgId);
|
||||||
|
if (msgId) setLastMessageId(msgId);
|
||||||
|
|
||||||
|
isPlayingRef.current = true;
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
const prevDuration = durationRef.current;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (durationRef.current === prevDuration) {
|
||||||
|
setDurationState(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void el.play().catch((err) => {
|
||||||
|
isLoadingRef.current = false;
|
||||||
|
commitPlaying(false);
|
||||||
|
setLastError(err instanceof Error ? err.message : "play() failed");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
commitPlaying(true);
|
||||||
|
|
||||||
|
void el.play().catch((err) => {
|
||||||
|
commitPlaying(false);
|
||||||
|
setLastError(err instanceof Error ? err.message : "resume() failed");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
isLoadingRef.current = true;
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
isLoadingRef.current = false;
|
||||||
|
|
||||||
|
durationRef.current = 0;
|
||||||
|
setDurationState(0);
|
||||||
|
commitPlaying(false);
|
||||||
|
setCurrentMessageId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDuration = (sec: number) => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
isSeekingRef.current = true;
|
||||||
|
el.currentTime = Math.max(0, sec);
|
||||||
|
commitDuration(el.currentTime || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerContext.Provider
|
||||||
|
value={{
|
||||||
|
playAudio,
|
||||||
|
playing: isPlaying,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
setDuration,
|
||||||
|
duration,
|
||||||
|
totalDuration,
|
||||||
|
currentMessageId,
|
||||||
|
lastMessageId,
|
||||||
|
lastError,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
<audio ref={audioRef} />
|
||||||
|
</PlayerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/providers/PlayerProvider/usePlayerContext.ts
Normal file
10
app/providers/PlayerProvider/usePlayerContext.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { PlayerContext, PlayerContextValue } from "./PlayerProvider";
|
||||||
|
|
||||||
|
export function usePlayerContext() : PlayerContextValue {
|
||||||
|
const context = useContext(PlayerContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAudioPlayer must be used within a PlayerProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -6,14 +6,25 @@ export enum AttachmentType {
|
|||||||
MESSAGES = 1,
|
MESSAGES = 1,
|
||||||
FILE = 2,
|
FILE = 2,
|
||||||
AVATAR = 3,
|
AVATAR = 3,
|
||||||
CALL
|
CALL = 4,
|
||||||
|
VOICE = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Информация о транспортировке вложения, нужна для загрузки и скачивания вложений с транспортного сервера
|
||||||
|
*/
|
||||||
|
export interface AttachmentTransport {
|
||||||
|
transport_tag: string;
|
||||||
|
transport_server: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
blob: string;
|
blob: string;
|
||||||
type: AttachmentType;
|
type: AttachmentType;
|
||||||
preview: string;
|
preview: string;
|
||||||
|
transport: AttachmentTransport;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PacketMessage extends Packet {
|
export class PacketMessage extends Packet {
|
||||||
@@ -42,7 +53,7 @@ export class PacketMessage extends Packet {
|
|||||||
this.toPublicKey = stream.readString();
|
this.toPublicKey = stream.readString();
|
||||||
this.content = stream.readString();
|
this.content = stream.readString();
|
||||||
this.chachaKey = stream.readString();
|
this.chachaKey = stream.readString();
|
||||||
this.timestamp = stream.readInt64();
|
this.timestamp = Number(stream.readInt64());
|
||||||
this.privateKey = stream.readString();
|
this.privateKey = stream.readString();
|
||||||
this.messageId = stream.readString();
|
this.messageId = stream.readString();
|
||||||
let attachmentsCount = stream.readInt8();
|
let attachmentsCount = stream.readInt8();
|
||||||
@@ -51,7 +62,11 @@ export class PacketMessage extends Packet {
|
|||||||
let preview = stream.readString();
|
let preview = stream.readString();
|
||||||
let blob = stream.readString();
|
let blob = stream.readString();
|
||||||
let type = stream.readInt8() as AttachmentType;
|
let type = stream.readInt8() as AttachmentType;
|
||||||
this.attachments.push({id, preview, type, blob});
|
const transport : AttachmentTransport = {
|
||||||
|
transport_tag: stream.readString(),
|
||||||
|
transport_server: stream.readString()
|
||||||
|
}
|
||||||
|
this.attachments.push({id, preview, type, blob, transport});
|
||||||
}
|
}
|
||||||
this.aesChachaKey = stream.readString();
|
this.aesChachaKey = stream.readString();
|
||||||
}
|
}
|
||||||
@@ -63,7 +78,7 @@ export class PacketMessage extends Packet {
|
|||||||
stream.writeString(this.toPublicKey);
|
stream.writeString(this.toPublicKey);
|
||||||
stream.writeString(this.content);
|
stream.writeString(this.content);
|
||||||
stream.writeString(this.chachaKey);
|
stream.writeString(this.chachaKey);
|
||||||
stream.writeInt64(this.timestamp);
|
stream.writeInt64(BigInt(this.timestamp));
|
||||||
stream.writeString(this.privateKey);
|
stream.writeString(this.privateKey);
|
||||||
stream.writeString(this.messageId);
|
stream.writeString(this.messageId);
|
||||||
stream.writeInt8(this.attachments.length);
|
stream.writeInt8(this.attachments.length);
|
||||||
@@ -72,6 +87,8 @@ export class PacketMessage extends Packet {
|
|||||||
stream.writeString(this.attachments[i].preview);
|
stream.writeString(this.attachments[i].preview);
|
||||||
stream.writeString(this.attachments[i].blob);
|
stream.writeString(this.attachments[i].blob);
|
||||||
stream.writeInt8(this.attachments[i].type);
|
stream.writeInt8(this.attachments[i].type);
|
||||||
|
stream.writeString(this.attachments[i].transport.transport_tag);
|
||||||
|
stream.writeString(this.attachments[i].transport.transport_server);
|
||||||
}
|
}
|
||||||
stream.writeString(this.aesChachaKey);
|
stream.writeString(this.aesChachaKey);
|
||||||
return stream;
|
return stream;
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ export enum SignalType {
|
|||||||
KEY_EXCHANGE = 1,
|
KEY_EXCHANGE = 1,
|
||||||
ACTIVE_CALL = 2,
|
ACTIVE_CALL = 2,
|
||||||
END_CALL = 3,
|
END_CALL = 3,
|
||||||
CREATE_ROOM = 4,
|
/**
|
||||||
|
* Переведен в стадию активного, значит комната на SFU уже создана и можно начинать обмен сигналами WebRTC
|
||||||
|
*/
|
||||||
|
ACTIVE = 4,
|
||||||
END_CALL_BECAUSE_PEER_DISCONNECTED = 5,
|
END_CALL_BECAUSE_PEER_DISCONNECTED = 5,
|
||||||
END_CALL_BECAUSE_BUSY = 6
|
END_CALL_BECAUSE_BUSY = 6,
|
||||||
|
ACCEPT = 7,
|
||||||
|
RINGING_TIMEOUT = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,12 +33,8 @@ export class PacketSignalPeer extends Packet {
|
|||||||
|
|
||||||
private signalType: SignalType = SignalType.CALL;
|
private signalType: SignalType = SignalType.CALL;
|
||||||
|
|
||||||
/**
|
private callId: string = "";
|
||||||
* Используется если SignalType == CREATE_ROOM,
|
private joinToken: string = "";
|
||||||
* для идентификации комнаты на SFU сервере, в которой будет происходить обмен сигналами
|
|
||||||
* WebRTC для установления P2P соединения между участниками звонка
|
|
||||||
*/
|
|
||||||
private roomId: string = "";
|
|
||||||
|
|
||||||
|
|
||||||
public getPacketId(): number {
|
public getPacketId(): number {
|
||||||
@@ -42,7 +43,9 @@ export class PacketSignalPeer extends Packet {
|
|||||||
|
|
||||||
public _receive(stream: Stream): void {
|
public _receive(stream: Stream): void {
|
||||||
this.signalType = stream.readInt8();
|
this.signalType = stream.readInt8();
|
||||||
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
|
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY
|
||||||
|
|| this.signalType == SignalType.RINGING_TIMEOUT
|
||||||
|
|| this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.src = stream.readString();
|
this.src = stream.readString();
|
||||||
@@ -50,8 +53,9 @@ export class PacketSignalPeer extends Packet {
|
|||||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||||
this.sharedPublic = stream.readString();
|
this.sharedPublic = stream.readString();
|
||||||
}
|
}
|
||||||
if(this.signalType == SignalType.CREATE_ROOM){
|
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
|
||||||
this.roomId = stream.readString();
|
this.callId = stream.readString();
|
||||||
|
this.joinToken = stream.readString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +63,9 @@ export class PacketSignalPeer extends Packet {
|
|||||||
const stream = new Stream();
|
const stream = new Stream();
|
||||||
stream.writeInt16(this.getPacketId());
|
stream.writeInt16(this.getPacketId());
|
||||||
stream.writeInt8(this.signalType);
|
stream.writeInt8(this.signalType);
|
||||||
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
|
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY
|
||||||
|
|| this.signalType == SignalType.RINGING_TIMEOUT
|
||||||
|
|| this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
stream.writeString(this.src);
|
stream.writeString(this.src);
|
||||||
@@ -67,8 +73,9 @@ export class PacketSignalPeer extends Packet {
|
|||||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||||
stream.writeString(this.sharedPublic);
|
stream.writeString(this.sharedPublic);
|
||||||
}
|
}
|
||||||
if(this.signalType == SignalType.CREATE_ROOM){
|
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
|
||||||
stream.writeString(this.roomId);
|
stream.writeString(this.callId);
|
||||||
|
stream.writeString(this.joinToken);
|
||||||
}
|
}
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
@@ -105,12 +112,20 @@ export class PacketSignalPeer extends Packet {
|
|||||||
this.src = src;
|
this.src = src;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRoomId(): string {
|
public getCallId(): string {
|
||||||
return this.roomId;
|
return this.callId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setRoomId(roomId: string) {
|
public setCallId(callId: string) {
|
||||||
this.roomId = roomId;
|
this.callId = callId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getJoinToken(): string {
|
||||||
|
return this.joinToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setJoinToken(joinToken: string) {
|
||||||
|
this.joinToken = joinToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -18,14 +18,14 @@ export class PacketSync extends Packet {
|
|||||||
|
|
||||||
public _receive(stream: Stream): void {
|
public _receive(stream: Stream): void {
|
||||||
this.status = stream.readInt8() as SyncStatus;
|
this.status = stream.readInt8() as SyncStatus;
|
||||||
this.timestamp = stream.readInt64();
|
this.timestamp = Number(stream.readInt64());
|
||||||
}
|
}
|
||||||
|
|
||||||
public _send(): Promise<Stream> | Stream {
|
public _send(): Promise<Stream> | Stream {
|
||||||
let stream = new Stream();
|
let stream = new Stream();
|
||||||
stream.writeInt16(this.getPacketId());
|
stream.writeInt16(this.getPacketId());
|
||||||
stream.writeInt8(this.status);
|
stream.writeInt8(this.status);
|
||||||
stream.writeInt64(this.timestamp);
|
stream.writeInt64(BigInt(this.timestamp));
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,151 +1,372 @@
|
|||||||
export default class Stream {
|
export default class Stream {
|
||||||
|
private stream: Uint8Array;
|
||||||
|
private readPointer = 0; // bits
|
||||||
|
private writePointer = 0; // bits
|
||||||
|
|
||||||
private _stream: number[];
|
constructor(stream?: Uint8Array | number[]) {
|
||||||
private _readPoiner: number = 0;
|
if (!stream) {
|
||||||
private _writePointer: number = 0;
|
this.stream = new Uint8Array(0);
|
||||||
|
} else {
|
||||||
constructor(stream : number[] = []) {
|
const src = stream instanceof Uint8Array ? stream : Uint8Array.from(stream);
|
||||||
this._stream = stream;
|
this.stream = src;
|
||||||
}
|
this.writePointer = this.stream.length << 3;
|
||||||
|
|
||||||
public getStream(): number[] {
|
|
||||||
return this._stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setStream(stream: number[]) {
|
|
||||||
this._stream = stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
public writeInt8(value: number) {
|
|
||||||
const negationBit = value < 0 ? 1 : 0;
|
|
||||||
const int8Value = Math.abs(value) & 0xFF;
|
|
||||||
this._stream[this._writePointer >> 3] |= negationBit << (7 - (this._writePointer & 7));
|
|
||||||
this._writePointer++;
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
const bit = (int8Value >> (7 - i)) & 1;
|
|
||||||
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
|
|
||||||
this._writePointer++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readInt8(): number {
|
getStream(): Uint8Array {
|
||||||
let value = 0;
|
return this.stream.slice(0, this.length());
|
||||||
const negationBit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
|
||||||
this._readPoiner++;
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
|
||||||
value |= bit << (7 - i);
|
|
||||||
this._readPoiner++;
|
|
||||||
}
|
|
||||||
return negationBit ? -value : value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeBit(value: number) {
|
setStream(stream?: Uint8Array | number[]) {
|
||||||
const bit = value & 1;
|
if (!stream) {
|
||||||
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
|
this.stream = new Uint8Array(0);
|
||||||
this._writePointer++;
|
this.readPointer = 0;
|
||||||
|
this.writePointer = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const src = stream instanceof Uint8Array ? stream : Uint8Array.from(stream);
|
||||||
|
this.stream = src;
|
||||||
|
this.readPointer = 0;
|
||||||
|
this.writePointer = this.stream.length << 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
public readBit(): number {
|
getBuffer(): Uint8Array {
|
||||||
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
return this.getStream();
|
||||||
this._readPoiner++;
|
|
||||||
return bit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeBoolean(value: boolean) {
|
isEmpty(): boolean {
|
||||||
|
return this.writePointer === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
length(): number {
|
||||||
|
return (this.writePointer + 7) >> 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- bit / boolean ----------
|
||||||
|
|
||||||
|
writeBit(value: number) {
|
||||||
|
this.writeBits(BigInt(value & 1), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
readBit(): number {
|
||||||
|
return Number(this.readBits(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBoolean(value: boolean) {
|
||||||
this.writeBit(value ? 1 : 0);
|
this.writeBit(value ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public readBoolean(): boolean {
|
readBoolean(): boolean {
|
||||||
return this.readBit() === 1;
|
return this.readBit() === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeInt16(value: number) {
|
// ---------- byte ----------
|
||||||
this.writeInt8(value >> 8);
|
|
||||||
this.writeInt8(value & 0xFF);
|
writeByte(b: number) {
|
||||||
|
this.writeUInt8(b & 0xff);
|
||||||
}
|
}
|
||||||
|
|
||||||
public readInt16(): number {
|
readByte(): number {
|
||||||
const value = this.readInt8() << 8;
|
const v = this.readUInt8();
|
||||||
return value | this.readInt8();
|
return (v << 24) >> 24; // signed byte
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeInt32(value: number) {
|
// ---------- UInt / Int 8 ----------
|
||||||
this.writeInt16(value >> 16);
|
|
||||||
this.writeInt16(value & 0xFFFF);
|
writeUInt8(value: number) {
|
||||||
|
const v = value & 0xff;
|
||||||
|
|
||||||
|
if ((this.writePointer & 7) === 0) {
|
||||||
|
this.reserveBits(8);
|
||||||
|
this.stream[this.writePointer >> 3] = v;
|
||||||
|
this.writePointer += 8;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public readInt32(): number {
|
this.writeBits(BigInt(v), 8);
|
||||||
const value = this.readInt16() << 16;
|
|
||||||
return value | this.readInt16();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeInt64(value: number) {
|
readUInt8(): number {
|
||||||
const high = Math.floor(value / 0x100000000);
|
if (this.remainingBits() < 8n) {
|
||||||
const low = value >>> 0;
|
throw new Error("Not enough bits to read UInt8");
|
||||||
this.writeInt32(high);
|
|
||||||
this.writeInt32(low);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public readInt64(): number {
|
if ((this.readPointer & 7) === 0) {
|
||||||
const high = this.readInt32();
|
const v = this.stream[this.readPointer >> 3] & 0xff;
|
||||||
const low = this.readInt32() >>> 0;
|
this.readPointer += 8;
|
||||||
return high * 0x100000000 + low;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeFloat32(value: number) {
|
return Number(this.readBits(8));
|
||||||
const buffer = new ArrayBuffer(4);
|
|
||||||
new DataView(buffer).setFloat32(0, value, true);
|
|
||||||
const float32Value = new Uint32Array(buffer)[0];
|
|
||||||
this.writeInt32(float32Value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public readFloat32(): number {
|
writeInt8(value: number) {
|
||||||
const float32Value = this.readInt32();
|
this.writeUInt8(value);
|
||||||
const buffer = new ArrayBuffer(4);
|
|
||||||
new Uint32Array(buffer)[0] = float32Value;
|
|
||||||
return new DataView(buffer).getFloat32(0, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeString(value: string) {
|
readInt8(): number {
|
||||||
let length = value.length;
|
const u = this.readUInt8();
|
||||||
this.writeInt32(length);
|
return (u << 24) >> 24;
|
||||||
for (let i = 0; i < value.length; i++) {
|
}
|
||||||
this.writeInt16(value.charCodeAt(i));
|
|
||||||
|
// ---------- UInt / Int 16 ----------
|
||||||
|
|
||||||
|
writeUInt16(value: number) {
|
||||||
|
const v = value & 0xffff;
|
||||||
|
this.writeUInt8((v >>> 8) & 0xff);
|
||||||
|
this.writeUInt8(v & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
readUInt16(): number {
|
||||||
|
const hi = this.readUInt8();
|
||||||
|
const lo = this.readUInt8();
|
||||||
|
return (hi << 8) | lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeInt16(value: number) {
|
||||||
|
this.writeUInt16(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
readInt16(): number {
|
||||||
|
const u = this.readUInt16();
|
||||||
|
return (u << 16) >> 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- UInt / Int 32 ----------
|
||||||
|
|
||||||
|
writeUInt32(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value < 0 || value > 0xffffffff) {
|
||||||
|
throw new Error(`UInt32 out of range: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = Math.floor(value);
|
||||||
|
this.writeUInt8((v >>> 24) & 0xff);
|
||||||
|
this.writeUInt8((v >>> 16) & 0xff);
|
||||||
|
this.writeUInt8((v >>> 8) & 0xff);
|
||||||
|
this.writeUInt8(v & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
readUInt32(): number {
|
||||||
|
const b1 = this.readUInt8();
|
||||||
|
const b2 = this.readUInt8();
|
||||||
|
const b3 = this.readUInt8();
|
||||||
|
const b4 = this.readUInt8();
|
||||||
|
return (((b1 * 0x1000000) + (b2 << 16) + (b3 << 8) + b4) >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeInt32(value: number) {
|
||||||
|
this.writeUInt32(value >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
readInt32(): number {
|
||||||
|
return this.readUInt32() | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- UInt / Int 64 ----------
|
||||||
|
|
||||||
|
writeUInt64(value: bigint) {
|
||||||
|
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) {
|
||||||
|
throw new Error(`UInt64 out of range: ${value.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writeUInt8(Number((value >> 56n) & 0xffn));
|
||||||
|
this.writeUInt8(Number((value >> 48n) & 0xffn));
|
||||||
|
this.writeUInt8(Number((value >> 40n) & 0xffn));
|
||||||
|
this.writeUInt8(Number((value >> 32n) & 0xffn));
|
||||||
|
this.writeUInt8(Number((value >> 24n) & 0xffn));
|
||||||
|
this.writeUInt8(Number((value >> 16n) & 0xffn));
|
||||||
|
this.writeUInt8(Number((value >> 8n) & 0xffn));
|
||||||
|
this.writeUInt8(Number(value & 0xffn));
|
||||||
|
}
|
||||||
|
|
||||||
|
readUInt64(): bigint {
|
||||||
|
const high = BigInt(this.readUInt32() >>> 0);
|
||||||
|
const low = BigInt(this.readUInt32() >>> 0);
|
||||||
|
return (high << 32n) | low;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeInt64(value: bigint) {
|
||||||
|
const u = BigInt.asUintN(64, value);
|
||||||
|
this.writeUInt64(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
readInt64(): bigint {
|
||||||
|
return BigInt.asIntN(64, this.readUInt64());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- float ----------
|
||||||
|
|
||||||
|
writeFloat32(value: number) {
|
||||||
|
const ab = new ArrayBuffer(4);
|
||||||
|
const dv = new DataView(ab);
|
||||||
|
dv.setFloat32(0, value, false); // big-endian
|
||||||
|
this.writeUInt8(dv.getUint8(0));
|
||||||
|
this.writeUInt8(dv.getUint8(1));
|
||||||
|
this.writeUInt8(dv.getUint8(2));
|
||||||
|
this.writeUInt8(dv.getUint8(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
readFloat32(): number {
|
||||||
|
const ab = new ArrayBuffer(4);
|
||||||
|
const dv = new DataView(ab);
|
||||||
|
dv.setUint8(0, this.readUInt8());
|
||||||
|
dv.setUint8(1, this.readUInt8());
|
||||||
|
dv.setUint8(2, this.readUInt8());
|
||||||
|
dv.setUint8(3, this.readUInt8());
|
||||||
|
return dv.getFloat32(0, false); // big-endian
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- string / bytes ----------
|
||||||
|
// String: length(UInt32) + chars(UInt16), как в Java charAt()
|
||||||
|
|
||||||
|
writeString(value: string | null | undefined) {
|
||||||
|
const s = value ?? "";
|
||||||
|
this.writeUInt32(s.length);
|
||||||
|
|
||||||
|
if (s.length === 0) return;
|
||||||
|
|
||||||
|
this.reserveBits(BigInt(s.length) * 16n);
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
this.writeUInt16(s.charCodeAt(i) & 0xffff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readString(): string {
|
readString(): string {
|
||||||
let length = this.readInt32();
|
const len = this.readUInt32();
|
||||||
/**
|
if (len > 0x7fffffff) {
|
||||||
* Фикс уязвимости с длинной строки, превышающей
|
throw new Error(`String length too large: ${len}`);
|
||||||
* возможность для чтения _stream
|
|
||||||
*/
|
|
||||||
if (length < 0 || length > (this._stream.length - (this._readPoiner >> 3))) {
|
|
||||||
console.info("Stream readString length invalid", length, this._stream.length, this._readPoiner);
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
let value = "";
|
|
||||||
for (let i = 0; i < length; i++) {
|
const requiredBits = BigInt(len) * 16n;
|
||||||
value += String.fromCharCode(this.readInt16());
|
if (requiredBits > this.remainingBits()) {
|
||||||
|
throw new Error("Not enough bits to read string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const chars = new Array<number>(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
chars[i] = this.readUInt16();
|
||||||
|
}
|
||||||
|
return String.fromCharCode(...chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
// byte[]: length(UInt32) + payload
|
||||||
|
writeBytes(value: Uint8Array | number[] | null | undefined) {
|
||||||
|
const arr = value == null
|
||||||
|
? new Uint8Array(0)
|
||||||
|
: (value instanceof Uint8Array ? value : Uint8Array.from(value));
|
||||||
|
|
||||||
|
this.writeUInt32(arr.length);
|
||||||
|
if (arr.length === 0) return;
|
||||||
|
|
||||||
|
this.reserveBits(BigInt(arr.length) * 8n);
|
||||||
|
|
||||||
|
if ((this.writePointer & 7) === 0) {
|
||||||
|
const byteIndex = this.writePointer >> 3;
|
||||||
|
this.ensureCapacity(byteIndex + arr.length - 1);
|
||||||
|
this.stream.set(arr, byteIndex);
|
||||||
|
this.writePointer += arr.length << 3;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
this.writeUInt8(arr[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readBytes(): Uint8Array {
|
||||||
|
const len = this.readUInt32();
|
||||||
|
if (len === 0) return new Uint8Array(0);
|
||||||
|
|
||||||
|
const requiredBits = BigInt(len) * 8n;
|
||||||
|
if (requiredBits > this.remainingBits()) {
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
|
||||||
|
if ((this.readPointer & 7) === 0) {
|
||||||
|
const byteIndex = this.readPointer >> 3;
|
||||||
|
out.set(this.stream.slice(byteIndex, byteIndex + len));
|
||||||
|
this.readPointer += len << 3;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
out[i] = this.readUInt8();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- internals ----------
|
||||||
|
|
||||||
|
private remainingBits(): bigint {
|
||||||
|
return BigInt(this.writePointer - this.readPointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeBits(value: bigint, bits: number) {
|
||||||
|
if (bits <= 0) return;
|
||||||
|
|
||||||
|
this.reserveBits(bits);
|
||||||
|
|
||||||
|
for (let i = bits - 1; i >= 0; i--) {
|
||||||
|
const bit = Number((value >> BigInt(i)) & 1n);
|
||||||
|
const byteIndex = this.writePointer >> 3;
|
||||||
|
const shift = 7 - (this.writePointer & 7);
|
||||||
|
|
||||||
|
if (bit === 1) {
|
||||||
|
this.stream[byteIndex] = this.stream[byteIndex] | (1 << shift);
|
||||||
|
} else {
|
||||||
|
this.stream[byteIndex] = this.stream[byteIndex] & ~(1 << shift);
|
||||||
|
}
|
||||||
|
this.writePointer++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readBits(bits: number): bigint {
|
||||||
|
if (bits <= 0) return 0n;
|
||||||
|
if (this.remainingBits() < BigInt(bits)) {
|
||||||
|
throw new Error("Not enough bits to read");
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = 0n;
|
||||||
|
for (let i = 0; i < bits; i++) {
|
||||||
|
const bit = (this.stream[this.readPointer >> 3] >> (7 - (this.readPointer & 7))) & 1;
|
||||||
|
value = (value << 1n) | BigInt(bit);
|
||||||
|
this.readPointer++;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeBytes(value: number[]) {
|
private reserveBits(bitsToWrite: number | bigint) {
|
||||||
this.writeInt32(value.length);
|
const bits = typeof bitsToWrite === "number" ? BigInt(bitsToWrite) : bitsToWrite;
|
||||||
for (let i = 0; i < value.length; i++) {
|
if (bits <= 0n) return;
|
||||||
this.writeInt8(value[i]);
|
|
||||||
}
|
const lastBitIndex = BigInt(this.writePointer) + bits - 1n;
|
||||||
|
if (lastBitIndex < 0n) throw new Error("Bit index overflow");
|
||||||
|
|
||||||
|
const byteIndex = lastBitIndex >> 3n;
|
||||||
|
if (byteIndex > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||||
|
throw new Error("Stream too large");
|
||||||
}
|
}
|
||||||
|
|
||||||
public readBytes(): number[] {
|
this.ensureCapacity(Number(byteIndex));
|
||||||
let length = this.readInt32();
|
|
||||||
let value : any = [];
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
value.push(this.readInt8());
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureCapacity(byteIndex: number) {
|
||||||
|
const requiredSize = byteIndex + 1;
|
||||||
|
if (requiredSize <= this.stream.length) return;
|
||||||
|
|
||||||
|
let newSize = this.stream.length === 0 ? 32 : this.stream.length;
|
||||||
|
while (newSize < requiredSize) {
|
||||||
|
if (newSize > (0x7fffffff >> 1)) {
|
||||||
|
newSize = requiredSize;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
newSize <<= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Uint8Array(newSize);
|
||||||
|
next.set(this.stream);
|
||||||
|
this.stream = next;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
|||||||
interface TransportContextValue {
|
interface TransportContextValue {
|
||||||
transportServer: string | null;
|
transportServer: string | null;
|
||||||
uploadFile: (id: string, content: string) => Promise<any>;
|
uploadFile: (id: string, content: string) => Promise<any>;
|
||||||
downloadFile: (id: string, tag: string) => Promise<string>;
|
downloadFile: (id: string, tag: string, transportServer: string) => Promise<string>;
|
||||||
uploading: TransportState[];
|
uploading: TransportState[];
|
||||||
downloading: TransportState[];
|
downloading: TransportState[];
|
||||||
}
|
}
|
||||||
@@ -86,14 +86,14 @@ export function TransportProvider(props: TransportProviderProps) {
|
|||||||
* @param tag тег файла
|
* @param tag тег файла
|
||||||
* @param chachaDecryptedKey ключ для расшифровки файла
|
* @param chachaDecryptedKey ключ для расшифровки файла
|
||||||
*/
|
*/
|
||||||
const downloadFile = (id: string, tag : string) : Promise<string> => {
|
const downloadFile = (id: string, tag : string, transportServer: string) : Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!transportServerRef.current) {
|
if (!transportServer) {
|
||||||
throw new Error("Transport server is not set");
|
throw new Error("Transport server is not set");
|
||||||
}
|
}
|
||||||
setDownloading(prev => [...prev, { id: id, progress: 0 }]);
|
setDownloading(prev => [...prev, { id: id, progress: 0 }]);
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', `${transportServerRef.current}/d/${tag}`);
|
xhr.open('GET', `${transportServer}/d/${tag}`);
|
||||||
xhr.responseType = 'text';
|
xhr.responseType = 'text';
|
||||||
|
|
||||||
xhr.onprogress = (event) => {
|
xhr.onprogress = (event) => {
|
||||||
|
|||||||
12
app/providers/TransportProvider/useTransportServer.ts
Normal file
12
app/providers/TransportProvider/useTransportServer.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { TransportContext } from "./TransportProvider";
|
||||||
|
|
||||||
|
export function useTransportServer() {
|
||||||
|
const context = useContext(TransportContext);
|
||||||
|
if(!context){
|
||||||
|
throw new Error("useTransportServer must be used within a TransportProvider");
|
||||||
|
}
|
||||||
|
const { transportServer } = context;
|
||||||
|
|
||||||
|
return transportServer;
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
|
|||||||
return "$a=Avatar";
|
return "$a=Avatar";
|
||||||
case AttachmentType.CALL:
|
case AttachmentType.CALL:
|
||||||
return "$a=Call";
|
return "$a=Call";
|
||||||
|
case AttachmentType.VOICE:
|
||||||
|
return "$a=Voice message";
|
||||||
default:
|
default:
|
||||||
return "[Unsupported attachment]";
|
return "[Unsupported attachment]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
export const APP_VERSION = "1.1.2";
|
export const APP_VERSION = "1.2.2";
|
||||||
export const CORE_MIN_REQUIRED_VERSION = "1.5.3";
|
export const CORE_MIN_REQUIRED_VERSION = "1.5.5";
|
||||||
|
|
||||||
export const RELEASE_NOTICE = `
|
export const RELEASE_NOTICE = `
|
||||||
**Обновление v1.1.2** :emoji_1f631:
|
**Обновление v1.2.2** :emoji_1f631:
|
||||||
- Улучшено шифрование звонков, теперь они более производительне и стабильные.
|
- Поддержка записи и прослушивания голосовых сообщений
|
||||||
- Добавлены события звонков (начало, окончание, пропущенные).
|
|
||||||
- Улучшена организация кода.
|
|
||||||
- Исправлены мелкие баги и улучшена стабильность приложения.
|
|
||||||
`;
|
`;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ColorSwatch, Text, useComputedColorScheme } from "@mantine/core";
|
import { Button, ColorSwatch, Flex, Text, useComputedColorScheme } from "@mantine/core";
|
||||||
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
|
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Breadcrumbs } from "@/app/components/Breadcrumbs/Breadcrumbs";
|
import { Breadcrumbs } from "@/app/components/Breadcrumbs/Breadcrumbs";
|
||||||
@@ -17,6 +17,7 @@ import { SettingsIcon } from "@/app/components/SettingsIcon/SettingsIcon";
|
|||||||
import { IconBrush, IconHomeCog, IconLogout, IconRefresh } from "@tabler/icons-react";
|
import { IconBrush, IconHomeCog, IconLogout, IconRefresh } from "@tabler/icons-react";
|
||||||
import { useLogout } from "@/app/providers/AccountProvider/useLogout";
|
import { useLogout } from "@/app/providers/AccountProvider/useLogout";
|
||||||
import { RosettaPower } from "@/app/components/RosettaPower/RosettaPower";
|
import { RosettaPower } from "@/app/components/RosettaPower/RosettaPower";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
|
||||||
export function MyProfile() {
|
export function MyProfile() {
|
||||||
const publicKey = usePublicKey();
|
const publicKey = usePublicKey();
|
||||||
@@ -28,8 +29,34 @@ export function MyProfile() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const send = useSender();
|
const send = useSender();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
|
const [usernameError, setUsernameError] = useState(false);
|
||||||
|
|
||||||
|
const openProfileModal = (text : string) => {
|
||||||
|
modals.open({
|
||||||
|
centered: true,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Text size="sm">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
<Flex align={'center'} justify={'flex-end'}>
|
||||||
|
<Button style={{
|
||||||
|
outline: 'none'
|
||||||
|
}} color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
withCloseButton: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const saveProfileData = () => {
|
const saveProfileData = () => {
|
||||||
|
if(usernameError) {
|
||||||
|
openProfileModal("You enter invalid username. Username must be a latin chars in lowercase.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let packet = new PacketUserInfo();
|
let packet = new PacketUserInfo();
|
||||||
packet.setUsername(username);
|
packet.setUsername(username);
|
||||||
packet.setTitle(title);
|
packet.setTitle(title);
|
||||||
@@ -70,10 +97,13 @@ export function MyProfile() {
|
|||||||
<SettingsInput.Default
|
<SettingsInput.Default
|
||||||
hit="Username"
|
hit="Username"
|
||||||
value={username}
|
value={username}
|
||||||
|
onErrorStateChange={(error) => setUsernameError(error)}
|
||||||
placeholder="ex. freddie871"
|
placeholder="ex. freddie871"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
regexp={new RegExp(/^([a-z][a-z0-9_]{4,15})?$/)}
|
||||||
></SettingsInput.Default>
|
></SettingsInput.Default>
|
||||||
</SettingsInput.Group>
|
</SettingsInput.Group>
|
||||||
|
{usernameError && <Text c={'red'} fz={10} pl={'xs'} mt={3}>Invalid username.</Text>}
|
||||||
<SettingsInput.Copy mt={'sm'} hit="Public Key" value={
|
<SettingsInput.Copy mt={'sm'} hit="Public Key" value={
|
||||||
publicKey
|
publicKey
|
||||||
} placeholder="Public"></SettingsInput.Copy>
|
} placeholder="Public"></SettingsInput.Copy>
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export function createPreloaderWindow() {
|
|||||||
|
|
||||||
export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 900,
|
width: 385,
|
||||||
height: 670,
|
height: 555,
|
||||||
minWidth: 385,
|
minWidth: 385,
|
||||||
minHeight: 555,
|
minHeight: 555,
|
||||||
show: false,
|
show: false,
|
||||||
|
|||||||
159
lib/main/main.ts
159
lib/main/main.ts
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, Menu, nativeImage } from 'electron'
|
import { app, BrowserWindow, Menu, Tray, nativeImage } from 'electron'
|
||||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||||
import { createAppWindow, startApplication } from './app'
|
import { createAppWindow, startApplication } from './app'
|
||||||
import './ipcs/ipcDatabase'
|
import './ipcs/ipcDatabase'
|
||||||
@@ -9,96 +9,137 @@ import './ipcs/ipcNotification'
|
|||||||
import './ipcs/ipcDevice'
|
import './ipcs/ipcDevice'
|
||||||
import './ipcs/ipcCore'
|
import './ipcs/ipcCore'
|
||||||
import './ipcs/ipcRuntime'
|
import './ipcs/ipcRuntime'
|
||||||
import { Tray } from 'electron/main'
|
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { Logger } from './logger'
|
import { Logger } from './logger'
|
||||||
|
|
||||||
let lockInstance = app.requestSingleInstanceLock();
|
const lockInstance = app.requestSingleInstanceLock()
|
||||||
let tray : Tray | null = null;
|
let tray: Tray | null = null
|
||||||
const size = process.platform === 'darwin' ? 18 : 22;
|
const size = process.platform === 'darwin' ? 18 : 22
|
||||||
const logger = Logger('main');
|
const logger = Logger('main')
|
||||||
|
|
||||||
|
const icon = nativeImage
|
||||||
const icon = nativeImage.createFromPath(
|
.createFromPath(join(__dirname, '../../resources/R.png'))
|
||||||
join(__dirname, '../../resources/R.png')
|
.resize({ width: size, height: size })
|
||||||
).resize({ width: size, height: size });
|
|
||||||
|
|
||||||
if (!lockInstance) {
|
if (!lockInstance) {
|
||||||
app.quit();
|
app.quit()
|
||||||
process.exit(0);
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
logger.log(`main thread error, reason: ${reason}`);
|
logger.log(`main thread error, reason: ${reason}`)
|
||||||
});
|
})
|
||||||
|
|
||||||
app.disableHardwareAcceleration();
|
app.disableHardwareAcceleration()
|
||||||
|
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', () => {
|
||||||
// Someone tried to run a second instance, we should focus our window.
|
const allWindows = BrowserWindow.getAllWindows()
|
||||||
const allWindows = BrowserWindow.getAllWindows();
|
|
||||||
if (allWindows.length) {
|
if (allWindows.length) {
|
||||||
const mainWindow = allWindows[0];
|
const mainWindow = allWindows[0]
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
if (mainWindow.isVisible() === false) mainWindow.show();
|
if (!mainWindow.isVisible()) mainWindow.show()
|
||||||
mainWindow.focus();
|
mainWindow.focus()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
export const restoreApplicationAfterClickOnTrayOrDock = () => {
|
export const restoreApplicationAfterClickOnTrayOrDock = () => {
|
||||||
const allWindows = BrowserWindow.getAllWindows();
|
const allWindows = BrowserWindow.getAllWindows()
|
||||||
if (allWindows.length > 0) {
|
if (allWindows.length > 0) {
|
||||||
const mainWindow = allWindows[0];
|
const mainWindow = allWindows[0]
|
||||||
if (mainWindow.isMinimized()) {
|
if (mainWindow.isMinimized()) {
|
||||||
mainWindow.restore();
|
mainWindow.restore()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
if(mainWindow.isVisible() === false){
|
if (!mainWindow.isVisible()) {
|
||||||
mainWindow.show();
|
mainWindow.show()
|
||||||
}
|
}
|
||||||
mainWindow.focus();
|
mainWindow.focus()
|
||||||
} else {
|
} else {
|
||||||
createAppWindow();
|
createAppWindow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Menu.setApplicationMenu(null);
|
|
||||||
// This method will be called when Electron has finished
|
|
||||||
// initialization and is ready to create browser windows.
|
|
||||||
// Some APIs can only be used after this event occurs.
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
electronApp.setAppUserModelId('Rosetta');
|
electronApp.setAppUserModelId('Rosetta')
|
||||||
tray = new Tray(icon);
|
|
||||||
|
// Убираем File/View и оставляем только app + минимальный Edit (roles)
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const minimalMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: app.name,
|
||||||
|
submenu: [
|
||||||
|
{ role: 'about' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'hide' },
|
||||||
|
{ role: 'hideOthers' },
|
||||||
|
{ role: 'unhide' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'quit' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'undo' },
|
||||||
|
{ role: 'redo' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'cut' },
|
||||||
|
{ role: 'copy' },
|
||||||
|
{ role: 'paste' },
|
||||||
|
{ role: 'pasteAndMatchStyle' },
|
||||||
|
{ role: 'delete' },
|
||||||
|
{ role: 'selectAll' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
Menu.setApplicationMenu(minimalMenu)
|
||||||
|
} else {
|
||||||
|
Menu.setApplicationMenu(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
tray = new Tray(icon)
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
{ label: 'Open App', click: () => restoreApplicationAfterClickOnTrayOrDock() },
|
{ label: 'Open App', click: () => restoreApplicationAfterClickOnTrayOrDock() },
|
||||||
{ label: 'Quit', click: () => app.quit() }
|
{ label: 'Quit', click: () => app.quit() }
|
||||||
]);
|
])
|
||||||
tray.setContextMenu(contextMenu);
|
tray.setContextMenu(contextMenu)
|
||||||
tray.setToolTip('Rosetta');
|
tray.setToolTip('Rosetta')
|
||||||
tray.on('click', () => {
|
tray.on('click', () => {
|
||||||
restoreApplicationAfterClickOnTrayOrDock();
|
restoreApplicationAfterClickOnTrayOrDock()
|
||||||
});
|
})
|
||||||
startApplication();
|
|
||||||
|
startApplication()
|
||||||
|
|
||||||
|
const isDevBuild =
|
||||||
|
!app.isPackaged ||
|
||||||
|
process.env.NODE_ENV === 'development' ||
|
||||||
|
Boolean(process.env.ELECTRON_RENDERER_URL)
|
||||||
|
|
||||||
// Default open or close DevTools by F12 in development
|
|
||||||
// and ignore CommandOrControl + R in production.
|
|
||||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
|
// В production оставляем стандартную защиту шорткатов
|
||||||
|
if (!isDevBuild) {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
|
||||||
app.on('activate', function () {
|
// В dev явно разрешаем Ctrl+R и Cmd+R для перезагрузки, так как в режиме разработки это часто нужно
|
||||||
restoreApplicationAfterClickOnTrayOrDock();
|
window.webContents.on('before-input-event', (event, input) => {
|
||||||
});
|
const key = input.key?.toLowerCase?.() ?? ''
|
||||||
})
|
const isReload = input.type === 'keyDown' && (input.meta || input.control) && key === 'r'
|
||||||
|
if (isReload) {
|
||||||
// Quit when all windows are closed, except on macOS. There, it's common
|
event.preventDefault()
|
||||||
// for applications and their menu bar to stay active until the user quits
|
window.webContents.reloadIgnoringCache()
|
||||||
// explicitly with Cmd + Q.
|
}
|
||||||
app.on('window-all-closed', () => {
|
})
|
||||||
if (process.platform == 'darwin') {
|
})
|
||||||
app.hide();
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
restoreApplicationAfterClickOnTrayOrDock()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
app.hide()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// In this file, you can include the rest of your app's specific main process
|
|
||||||
// code. You can also put them in separate files and import them here.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Rosetta",
|
"name": "Rosetta",
|
||||||
"version": "1.5.3",
|
"version": "1.5.5",
|
||||||
"description": "Rosetta Messenger",
|
"description": "Rosetta Messenger",
|
||||||
"main": "./out/main/main.js",
|
"main": "./out/main/main.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Reference in New Issue
Block a user