Compare commits

...

31 Commits

Author SHA1 Message Date
523d67b01f 1.1.0-1.5.2
All checks were successful
Windows Kernel Build / build (push) Successful in 13m21s
MacOS Kernel Build / build (push) Successful in 13m57s
SP Builds / build (push) Successful in 6m41s
Linux Kernel Build / build (push) Successful in 29m21s
Reviewed-on: #16
2026-03-18 17:59:13 +00:00
RoyceDa
8f0e8e8251 Установка PROD сервера 2026-03-18 19:57:16 +02:00
RoyceDa
61d55f266f Поднятие версии 2026-03-18 19:56:39 +02:00
RoyceDa
824b1fec65 Правки в ядре для показа окна поверх всех окон при звонке 2026-03-18 18:28:37 +02:00
RoyceDa
41d7a89830 Дополнительные поправки по событийным звукам 2026-03-18 18:28:10 +02:00
RoyceDa
88288317ab Событийные звуки звонка (сбросить, мутинг, и прочее...) 2026-03-18 18:27:39 +02:00
RoyceDa
7b9936dcc4 Исправление неправильной отправки сетевого пакета при renegotation завершенного звонка 2026-03-17 19:19:36 +02:00
RoyceDa
fcf4204063 Обработка END_CALL_BECAUSE_BUSY и END_CALL_BECAUSE_PEER_DISCONNECTED 2026-03-17 18:39:06 +02:00
RoyceDa
6dd348230f Реализация динамического запроса транспортных серверов в соответствии с поправками в g365sfu 2026-03-17 15:02:57 +02:00
RoyceDa
2c026d596d Правильная обработка SDPOffer при renegotiation от SFU 2026-03-16 19:27:16 +02:00
RoyceDa
ab57303eb6 Буферизация ICE кандидатов (для избежания гонки) 2026-03-15 17:22:48 +02:00
RoyceDa
f57ec484e3 Исправлена опечатка TURN 2026-03-14 23:13:00 +02:00
RoyceDa
f0d0909382 Динамический запрос ICE серверов 2026-03-14 23:05:54 +02:00
RoyceDa
76442c4161 Обработка треков и IceCandidates 2026-03-14 20:23:29 +02:00
RoyceDa
0513a90036 Добавление треков (аудио) в RTCPeerConnection 2026-03-14 18:38:04 +02:00
RoyceDa
0600da5b7c Signal Peer исправление Src/Dst 2026-03-14 15:43:49 +02:00
RoyceDa
8dc2537cdc WebRTC пакет в протоколе 2026-03-14 15:36:26 +02:00
RoyceDa
2707bd2a39 Обработка WebRTC SDP/ICE 2026-03-14 15:28:24 +02:00
RoyceDa
ca36a8d818 Обмен SDP, создание комнаты, улучшенная организация кода 2026-03-14 15:28:01 +02:00
RoyceDa
e79282755b Финальный обмен ключами шифрования, все готово для установки WebRTC соединения 2026-03-11 17:22:29 +02:00
RoyceDa
e06d58facf Реализация сигналинга и обмена ключами 2026-03-02 18:53:15 +02:00
RoyceDa
7a89a3a307 OFFERS & ANSWERS webRTC 2026-02-28 18:31:21 +02:00
RoyceDa
9ad0e5d00a Правильные SignalType 2026-02-28 17:42:10 +02:00
RoyceDa
9eac2fae6f Обмен ключами шифрования DH 2026-02-28 17:33:23 +02:00
RoyceDa
461ccbfa94 Дизайн звонков 2026-02-28 12:48:53 +02:00
RoyceDa
8b16c4ce0f Подложка к вложению аватарки 2026-02-26 20:55:55 +02:00
RoyceDa
c3a53b517e Фикс ошибки чтения 2026-02-26 20:54:52 +02:00
RoyceDa
b9603462a0 Подложка под прозрачные аватарки 2026-02-26 12:40:19 +02:00
RoyceDa
84d3cc7be4 Прозрачным аватаркам добавлена подложка 2026-02-26 12:20:35 +02:00
RoyceDa
a431b23476 Прозрачным аватаркам добавлена подложка 2026-02-26 12:19:30 +02:00
RoyceDa
88369171b6 Запоминание выбора сообщения при переключении между диалогами 2026-02-26 00:19:49 +02:00
44 changed files with 1348 additions and 138 deletions

View File

@@ -6,9 +6,8 @@ import { ConfirmSeed } from './views/ConfirmSeed/ConfirmSeed';
import { SetPassword } from './views/SetPassword/SetPassword';
import { Main } from './views/Main/Main';
import { ExistsSeed } from './views/ExistsSeed/ExistsSeed';
import { Box, Divider } from '@mantine/core';
import { Box } from '@mantine/core';
import './style.css'
import { useRosettaColors } from './hooks/useRosettaColors';
import { Buffer } from 'buffer';
import { InformationProvider } from './providers/InformationProvider/InformationProvider';
import { BlacklistProvider } from './providers/BlacklistProvider/BlacklistProvider';
@@ -27,8 +26,6 @@ window.Buffer = Buffer;
export default function App() {
const { allAccounts, accountProviderLoaded } = useAccountProvider();
const colors = useRosettaColors();
const getViewByLoginState = () => {
if (!accountProviderLoaded) {
@@ -59,7 +56,6 @@ export default function App() {
<SystemAccountProvider>
<Box h={'100%'}>
<Topbar></Topbar>
<Divider color={colors.borderColor}></Divider>
<ContextMenuProvider>
<ImageViwerProvider>
<AvatarProvider>

View File

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

View File

@@ -0,0 +1,42 @@
.active {
background: linear-gradient(90deg,rgba(0, 186, 59, 1) 0%, rgba(0, 194, 81, 1) 50%);
background-size: 200% 200%;
animation: activeFlow 5s ease-in-out infinite;
}
@keyframes activeFlow {
0% {
background-position: 0% 50%;
filter: saturate(1);
}
50% {
background-position: 100% 50%;
filter: saturate(1.15);
}
100% {
background-position: 0% 50%;
filter: saturate(1);
}
}
.connecting {
background: linear-gradient(120deg, #ff2d2d, #ff7a00, #ff2d2d);
background-size: 220% 220%;
animation: connectingFlow 5s ease-in-out infinite;
}
@keyframes connectingFlow {
0% {
background-position: 0% 50%;
filter: saturate(1);
}
50% {
background-position: 100% 50%;
filter: saturate(1.15);
}
100% {
background-position: 0% 50%;
filter: saturate(1);
}
}
/* ...existing code... */

View File

@@ -0,0 +1,98 @@
import { useCalls } from "@/app/providers/CallProvider/useCalls";
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
import { Box, Flex, Loader, Text } from "@mantine/core";
import classes from "./ActiveCall.module.css";
import { CallState } from "@/app/providers/CallProvider/CallProvider";
import { IconMicrophone, IconMicrophoneOff, IconPhoneX, IconVolume, IconVolumeOff } from "@tabler/icons-react";
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
export function ActiveCall() {
const {activeCall, callState, duration, muted, sound, close, setMuted, setSound, setShowCallView} = useCalls();
const [userInfo] = useUserInformation(activeCall);
//const colors = useRosettaColors();
if(activeCall == ""){
return <></>
}
const getConnectingClass = () => {
if(callState === CallState.CONNECTING
|| callState === CallState.INCOMING
|| callState === CallState.KEY_EXCHANGE
|| callState === CallState.WEB_RTC_EXCHANGE){
return classes.connecting;
}
if(callState === CallState.ACTIVE){
return classes.active;
}
return "";
}
return (
<>
<Box py={4} style={{
cursor: 'pointer'
}} px={10} className={getConnectingClass()} onClick={() => setShowCallView(true)}>
<Flex align={'center'} justify={'row'} gap={10}>
<Flex w={'100%'} justify={'space-between'} align={'center'}>
<Flex>
{!muted && (
<IconMicrophoneOff style={{
cursor: 'pointer'
}} onClick={(e) => {
e.stopPropagation();
setMuted(true);
}} size={16} color={'#fff'}></IconMicrophoneOff>
)}
{muted && (
<IconMicrophone style={{
cursor: 'pointer'
}} onClick={(e) => {
e.stopPropagation();
setMuted(false);
}} size={16} color={'#fff'}></IconMicrophone>
)}
</Flex>
<Flex justify={'center'} align={'center'} gap={'xs'}>
<Text fw={500} c={'#fff'} style={{
userSelect: 'none'
}} fz={13}>{userInfo?.title || activeCall}</Text>
{callState === CallState.CONNECTING && (
<Loader type={'dots'} size={12} color="white"></Loader>
)}
{callState == CallState.ACTIVE && (
<Text fw={500} c={'#ffffff'} style={{
userSelect: 'none'
}} fz={12}>{translateDurationToTime(duration)}</Text>
)}
</Flex>
<Flex gap={'xs'} align={'center'} justify={'center'}>
{sound && (
<IconVolumeOff style={{
cursor: 'pointer'
}} size={16} onClick={(e) => {
e.stopPropagation();
setSound(false)
}} color={'#fff'}></IconVolumeOff>
)}
{!sound && (
<IconVolume style={{
cursor: 'pointer'
}} size={16} onClick={(e) => {
e.stopPropagation();
setSound(true)
}} color={'#fff'}></IconVolume>
)}
<IconPhoneX style={{
cursor: 'pointer'
}} onClick={(e) => {
e.stopPropagation();
close();
}} size={16} color={'#fff'}></IconPhoneX>
</Flex>
</Flex>
</Flex>
</Box>
</>
);
}

View File

@@ -0,0 +1,139 @@
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
import { CallContextValue, CallState } from "@/app/providers/CallProvider/CallProvider";
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
import { Avatar, Box, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { IconChevronLeft, IconMicrophone, IconMicrophoneOff, IconPhone, IconPhoneX, IconQrcode, IconVolume, IconVolumeOff, IconX } from "@tabler/icons-react";
import { KeyImage } from "../KeyImage/KeyImage";
export interface CallProps {
context: CallContextValue;
}
export function Call(props: CallProps) {
const {
activeCall,
duration,
callState,
close,
sound,
setSound,
setMuted,
setShowCallView,
muted,
getKeyCast,
accept
} = props.context;
const [userInfo] = useUserInformation(activeCall);
const avatars = useAvatars(activeCall);
const colors = useRosettaColors();
const theme = useMantineTheme();
return (
<Box pos={'absolute'} top={0} left={0} w={'100%'} h={'100vh'} style={{
zIndex: 11,
background: 'linear-gradient(120deg,#141414 0%, #000000 100%)',
}}>
<Flex h={'100%'} w={'100vw'} direction={'column'} gap={'lg'} pt={'xl'}>
<Flex direction={'row'} w={'100%'} gap={'sm'} align={'center'} justify={'space-between'} p={'sm'}>
<Flex style={{
cursor: 'pointer'
}} onClick={() => setShowCallView(false)} justify={'center'} align={'center'}>
<IconChevronLeft color="white" size={20}></IconChevronLeft>
<Text fw={500} c={'white'}>Back</Text>
</Flex>
<Flex>
<Popover width={300} disabled={getKeyCast() == ''} withArrow>
<Popover.Target>
<IconQrcode color={getKeyCast() == '' ? 'gray' : 'white'} size={24}></IconQrcode>
</Popover.Target>
<Popover.Dropdown p={'xs'}>
<Flex direction={'row'} align={'center'} gap={'xs'}>
<Text maw={300} c={'dimmed'} fz={'xs'}>
This call is secured by 256 bit end-to-end encryption. Only you and the recipient can read or listen to the content of this call.
</Text>
<KeyImage radius={0} colors={[
theme.colors.blue[1],
theme.colors.blue[2],
theme.colors.blue[3],
theme.colors.blue[4],
theme.colors.blue[5]
]} size={80} keyRender={getKeyCast()}></KeyImage>
</Flex>
</Popover.Dropdown>
</Popover>
</Flex>
</Flex>
<Flex direction={'column'} mt={'xl'} style={{
userSelect: 'none'
}} w={'100vw'} gap={'sm'} align={'center'} justify={'center'}>
<Avatar size={128} bg={avatars.length > 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} color={'initials'} name={userInfo.title}></Avatar>
<Text fz={20} fw={'bold'} c={'#FFF'}>{userInfo.title}</Text>
{callState == CallState.ACTIVE && (<Text fz={14} c={'#FFF'}>{translateDurationToTime(duration)}</Text>)}
{callState == CallState.CONNECTING && (<Text fz={14} c={'#FFF'}>Connecting...</Text>)}
{callState == CallState.INCOMING && (<Text fz={14} c={'#FFF'}>Incoming call...</Text>)}
{callState == CallState.KEY_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
{callState == CallState.WEB_RTC_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
<Flex gap={'xl'} align={'center'} justify={'center'} mt={'xl'}>
{(callState == CallState.ACTIVE
|| callState == CallState.WEB_RTC_EXCHANGE
|| callState == CallState.CONNECTING
|| callState == CallState.KEY_EXCHANGE) && (
<>
<Box w={50} onClick={() => setSound(!sound)} style={{
borderRadius: 25,
cursor: 'pointer'
}} h={50} bg={sound ? colors.chevrons.active : colors.chevrons.disabled}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
{!sound && <IconVolume size={24} color={'#fff'}></IconVolume>}
{sound && <IconVolumeOff size={24} color={'#fff'}></IconVolumeOff>}
</Flex>
</Box>
<Box w={50} onClick={() => setMuted(!muted)} style={{
borderRadius: 25,
cursor: 'pointer'
}} h={50} bg={!muted ? colors.chevrons.active : colors.chevrons.disabled}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
{muted && <IconMicrophone size={24} color={'#fff'}></IconMicrophone>}
{!muted && <IconMicrophoneOff size={24} color={'#fff'}></IconMicrophoneOff>}
</Flex>
</Box>
<Box w={50} onClick={close} style={{
borderRadius: 25,
cursor: 'pointer'
}} h={50} bg={colors.error}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
<IconPhoneX size={24} color={'#fff'}></IconPhoneX>
</Flex>
</Box>
</>
)}
{callState == CallState.INCOMING && (
<>
{userInfo.title != "Rosetta" && (
<Box w={50} onClick={close} style={{
borderRadius: 25,
cursor: 'pointer'
}} h={50} bg={colors.error}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
<IconX size={24} color={'#fff'}></IconX>
</Flex>
</Box>
)}
<Box w={userInfo.title != "Rosetta" ? 50 : 100} onClick={accept} style={{
borderRadius: '50%',
cursor: 'pointer'
}} h={userInfo.title != "Rosetta" ? 50 : 100} bg={colors.success}>
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
<IconPhone size={24} color={'#fff'}></IconPhone>
</Flex>
</Box>
</>
)}
</Flex>
</Flex>
</Flex>
</Box>
)
}

View File

@@ -1,14 +1,13 @@
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
import { Avatar, Box, Divider, Flex, Loader, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { Avatar, Box, Divider, Flex, Loader, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconBookmark, IconLockAccess, IconLockCancel, IconTrashX } from "@tabler/icons-react";
import { IconBookmark, IconPhone, IconTrashX } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
@@ -20,6 +19,7 @@ import { ReplyHeader } from "../ReplyHeader/ReplyHeader";
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
import { BackToDialogs } from "../BackToDialogs/BackToDialogs";
import { useSystemAccounts } from "@/app/providers/SystemAccountsProvider/useSystemAccounts";
import { useCalls } from "@/app/providers/CallProvider/useCalls";
export function ChatHeader() {
@@ -29,7 +29,6 @@ export function ChatHeader() {
const publicKey = usePublicKey();
const {deleteMessages, dialog} = useDialog();
const theme = useMantineTheme();
const [blocked, blockUser, unblockUser] = useBlacklist(dialog);
const [opponent, ___, forceUpdateUserInformation] = useUserInformation(dialog);
const [protocolState] = useProtocolState();
const [userTypeing, setUserTypeing] = useState(false);
@@ -39,6 +38,7 @@ export function ChatHeader() {
const {lg} = useRosettaBreakpoints();
const systemAccounts = useSystemAccounts();
const isSystemAccount = systemAccounts.find((acc) => acc.publicKey == dialog) != undefined;
const {call} = useCalls();
useEffect(() => {
@@ -78,20 +78,6 @@ export function ChatHeader() {
});
}
const onClickBlockUser = () => {
if(opponent.publicKey != "DELETED"
&& opponent.publicKey != publicKey){
blockUser();
}
}
const onClickUnblockUser = () => {
if(opponent.publicKey != "DELETED"
&& opponent.publicKey != publicKey){
unblockUser();
}
}
const onClickProfile = () => {
if(opponent.publicKey != "DELETED" && opponent.publicKey != publicKey){
navigate("/main/profile/" + opponent.publicKey);
@@ -116,7 +102,7 @@ export function ChatHeader() {
onClick={onClickProfile}
>
<IconBookmark stroke={2} size={20}></IconBookmark>
</Avatar> : <Avatar onClick={onClickProfile} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
</Avatar> : <Avatar onClick={onClickProfile} bg={avatars.length > 0 ? '#fff' : undefined} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
}
<Flex direction={'column'} onClick={onClickProfile}>
<Flex align={'center'} gap={3}>
@@ -149,32 +135,16 @@ export function ChatHeader() {
</Flex>
</Flex>
<Flex h={'100%'} align={'center'} gap={'sm'}>
<Tooltip onClick={onClickClearMessages} withArrow position={'bottom'} label={"Clear all messages"}>
<IconPhone
onClick={() => call(dialog)}
style={{
cursor: 'pointer'
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconPhone>
<IconTrashX
onClick={onClickClearMessages}
style={{
cursor: 'pointer'
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconTrashX>
</Tooltip>
{publicKey != opponent.publicKey && !blocked && !isSystemAccount && (
<Tooltip onClick={onClickBlockUser} withArrow position={'bottom'} label={"Block user"}>
<IconLockCancel
style={{
cursor: 'pointer'
}} stroke={1.5} color={theme.colors.red[7]} size={24}
>
</IconLockCancel>
</Tooltip>
)}
{blocked && !isSystemAccount && (
<Tooltip onClick={onClickUnblockUser} withArrow position={'bottom'} label={"Unblock user"}>
<IconLockAccess
style={{
cursor: 'pointer'
}} stroke={1.5} color={theme.colors.green[7]} size={24}
>
</IconLockAccess>
</Tooltip>
)}
</Flex>
</Flex>}
{replyMessages.messages.length > 0 && !replyMessages.inDialogInput && <ReplyHeader></ReplyHeader>}

View File

@@ -88,7 +88,7 @@ export function Dialog(props : DialogProps) {
<IconBookmark stroke={2} size={20}></IconBookmark>
</Avatar> :
<Box style={{ position: 'relative', display: 'inline-block' }}>
<Avatar src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} />
<Avatar bg={avatars.length > 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} />
{userInfo.online == OnlineState.ONLINE && (
<Box
style={{

View File

@@ -10,6 +10,8 @@ import { DialogsPanelHeader } from '../DialogsPanelHeader/DialogsPanelHeader';
import { useDialogsList } from '@/app/providers/DialogListProvider/useDialogsList';
import { useVerifyRequest } from '@/app/providers/DeviceProvider/useVerifyRequest';
import { DeviceVerify } from '../DeviceVerify/DeviceVerify';
import { ActiveCall } from '../ActiveCall/ActiveCall';
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
export function DialogsPanel() {
const [dialogsMode, setDialogsMode] = useState<'all' | 'requests'>('all');
@@ -18,6 +20,7 @@ export function DialogsPanel() {
const colors = useRosettaColors();
const navigate = useNavigate();
const device = useVerifyRequest();
const [viewState] = useViewPanelsState();
useEffect(() => {
((async () => {
@@ -52,6 +55,9 @@ export function DialogsPanel() {
direction={'column'}
justify={'space-between'}
>
{viewState == ViewPanelsState.DIALOGS_PANEL_ONLY && (
<ActiveCall></ActiveCall>
)}
<Box>
<DialogsPanelHeader></DialogsPanelHeader>
{device && (

View File

@@ -4,7 +4,7 @@
left: 12px;
display: flex;
gap: 8px;
z-index: 10;
z-index: 15;
app-region: no-drag;
}
.close_btn, .minimize_btn, .maximize_btn {

View File

@@ -22,6 +22,7 @@ export function MentionRow(props : MentionRowProps) {
name={props.title}
variant="light"
color="initials"
bg={avatars.length > 0 ? '#fff' : undefined}
src={avatars.length > 0 ? avatars[0].avatar : null}
></Avatar>}
<Flex direction={'column'}>

View File

@@ -78,7 +78,8 @@ export function MessageAvatar(props: AttachmentProps) {
height: 60,
width: 60,
borderRadius: '50%',
objectFit: 'cover'
objectFit: 'cover',
background: '#fff'
}} src={blob}></img>)}
{downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING && preview.length >= 20 && (
<>

View File

@@ -186,7 +186,7 @@ export function Message(props: MessageProps) {
{computedMessageStyle == MessageStyle.ROWS && (
<Flex direction={'row'} justify={'space-between'} gap={'sm'}>
<Flex direction={'row'} gap={'sm'}>
{(!props.avatar_no_render && (md || !props.replyed)) && <Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>}
{(!props.avatar_no_render && (md || !props.replyed)) && <Avatar bg={avatars.length > 0 ? '#fff' : undefined} onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>}
<Flex direction={'column'}>
<Flex direction={'row'} gap={3} align={'center'}>
{!props.avatar_no_render && (
@@ -262,7 +262,7 @@ export function Message(props: MessageProps) {
return (
<Flex direction={props.from_me ? 'row-reverse' : 'row'} gap={'sm'} align={'flex-end'}>
{(md && props.is_last_message_in_stack) && (
<Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar>
<Avatar bg={avatars.length > 0 ? '#fff' : undefined} onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar>
)}
{(md && !props.is_last_message_in_stack) && (
<Box style={{ width: 40, height: 40, flexShrink: 0 }}></Box>

View File

@@ -40,6 +40,7 @@ export function UserRow(props: UserRowProps) {
radius="xl"
name={userInfo.title}
color={'initials'}
bg={avatars.length > 0 ? '#fff' : undefined}
src={avatars.length > 0 ? avatars[0].avatar : undefined}
/>
<Flex direction={'column'}>

View File

@@ -6,6 +6,9 @@
</head>
<body>
<div id="app"></div>
<script>
window.global = window;
</script>
<script type="module" src="/renderer.tsx"></script>
</body>
</html>

78
app/hooks/useSound.ts Normal file
View File

@@ -0,0 +1,78 @@
import { useRef } from "react";
export function useSound() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const loopingAudioRef = useRef<HTMLAudioElement | null>(null);
const stopSound = () => {
if (!audioRef.current) {
return;
}
audioRef.current.pause();
audioRef.current.currentTime = 0;
audioRef.current.removeAttribute("src");
audioRef.current.load();
};
const playSound = (sound : string, loop: boolean = false) => {
try {
if(loop){
if (!loopingAudioRef.current) {
loopingAudioRef.current = new Audio();
loopingAudioRef.current.volume = 0.1;
loopingAudioRef.current.preload = "auto";
loopingAudioRef.current.loop = true;
}
const url = window.mediaApi.getSoundUrl(sound);
const player = loopingAudioRef.current;
player.src = url;
const playPromise = player.play();
if (playPromise) {
void playPromise.catch((e) => {
console.error("Failed to play looping UI sound:", e);
});
}
return;
}
if (!audioRef.current) {
audioRef.current = new Audio();
audioRef.current.volume = 0.1;
audioRef.current.preload = "auto";
audioRef.current.loop = loop;
}
const url = window.mediaApi.getSoundUrl(sound);
const player = audioRef.current;
stopSound();
player.src = url;
const playPromise = player.play();
if (playPromise) {
void playPromise.catch((e) => {
console.error("Failed to play UI sound:", e);
});
}
} catch (e) {
console.error("Failed to prepare UI sound:", e);
}
}
const stopLoopSound = () => {
if (!loopingAudioRef.current) {
return;
}
loopingAudioRef.current.pause();
loopingAudioRef.current.currentTime = 0;
loopingAudioRef.current.removeAttribute("src");
loopingAudioRef.current.load();
}
return {
playSound,
stopSound,
stopLoopSound
}
}

View File

@@ -20,10 +20,19 @@ const useWindow = () => {
window.api.send('window-theme', theme);
}
const setWindowPriority = (isTop: boolean) => {
if(isTop){
window.api.invoke('window-top');
} else {
window.api.invoke('window-priority-normal');
}
}
return {
setSize,
setResizeble,
setTheme
setTheme,
setWindowPriority
}
}

View File

@@ -0,0 +1,541 @@
import { Call } from "@/app/components/Call/Call";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { createContext, useEffect, useRef, useState } from "react";
import nacl from 'tweetnacl';
import { useSender } from "../ProtocolProvider/useSender";
import { PacketSignalPeer, SignalType } from "../ProtocolProvider/protocol/packets/packet.signal.peer";
import { usePacket } from "../ProtocolProvider/usePacket";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { PacketWebRTC, WebRTCSignalType } from "../ProtocolProvider/protocol/packets/packet.webrtc";
import { PacketIceServers } from "../ProtocolProvider/protocol/packets/packet.ice.servers";
import { modals } from "@mantine/modals";
import { Button, Flex, Text } from "@mantine/core";
import { useSound } from "@/app/hooks/useSound";
import useWindow from "@/app/hooks/useWindow";
export interface CallContextValue {
call: (callable: string) => void;
close: () => void;
activeCall: string;
callState: CallState;
muted: boolean;
sound: boolean;
setMuted: (muted: boolean) => void;
setSound: (sound: boolean) => void;
duration: number;
setShowCallView: (show: boolean) => void;
getKeyCast: () => string;
accept: () => void;
}
export enum CallState {
CONNECTING,
KEY_EXCHANGE,
/**
* Финальная стадия сигналинга, на которой обе стороны обменялись ключами и теперь устанавливают защищенный канал связи для звонка,
* через WebRTC, и готовятся к активному звонку.
*/
WEB_RTC_EXCHANGE,
ACTIVE,
ENDED,
INCOMING
}
export enum CallRole {
/**
* Вызывающая сторона, которая инициирует звонок
*/
CALLER,
/**
* Принимающая сторона, которая отвечает на звонок и принимает его
*/
CALLEE
}
export const CallContext = createContext<CallContextValue | null>(null);
export interface CallProviderProps {
children: React.ReactNode;
}
export function CallProvider(props : CallProviderProps) {
const [activeCall, setActiveCall] = useState<string>("");
const [callState, setCallState] = useState<CallState>(CallState.ENDED);
const [muted, setMutedState] = useState<boolean>(false);
const [sound, setSoundState] = useState<boolean>(true);
const durationIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [duration, setDuration] = useState<number>(0);
const [showCallView, setShowCallView] = useState<boolean>(callState == CallState.INCOMING);
const {info} = useConsoleLogger("CallProvider");
const [sessionKeys, setSessionKeys] = useState<nacl.BoxKeyPair | null>(null);
const send = useSender();
const publicKey = usePublicKey();
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const roomIdRef = useRef<string>("");
const roleRef = useRef<CallRole | null>(null);
const [sharedSecret, setSharedSecret] = useState<string>("");
const iceServersRef = useRef<RTCIceServer[]>([]);
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]);
const mutedRef = useRef<boolean>(false);
const soundRef = useRef<boolean>(true);
const {playSound, stopSound, stopLoopSound} = useSound();
const {setWindowPriority} = useWindow();
useEffect(() => {
if(callState == CallState.ACTIVE){
stopLoopSound();
stopSound();
playSound("connected.mp3");
setWindowPriority(false);
durationIntervalRef.current = setInterval(() => {
setDuration(prev => prev + 1);
}, 1000);
}
}, [callState]);
useEffect(() => {
/**
* Нам нужно получить ICE серверы для установки соединения из разных сетей
* Получаем их от сервера
*/
let packet = new PacketIceServers();
send(packet);
return () => {
stopSound();
if (remoteAudioRef.current) {
remoteAudioRef.current.pause();
remoteAudioRef.current.srcObject = null;
}
peerConnectionRef.current?.close();
peerConnectionRef.current = null;
};
}, []);
usePacket(28, async (packet: PacketIceServers) => {
let iceServers = packet.getIceServers();
/**
* ICE серверы получены, теперь нужно привести их к форматку клиента и добавить udp и tcp варианты
*/
let formattedIceServers: RTCIceServer[] = [];
for(let i = 0; i < iceServers.length; i++){
let server = iceServers[i];
formattedIceServers.push({
urls: "turn:" + server.url + "?transport=" + server.transport,
username: server.username,
credential: server.credential
});
}
iceServersRef.current = formattedIceServers;
info("Received ICE servers from server, count: " + formattedIceServers.length);
}, []);
usePacket(27, async (packet: PacketWebRTC) => {
if(callState != CallState.WEB_RTC_EXCHANGE && callState != CallState.ACTIVE){
/**
* Нет активного звонка или мы не на стадии обмена WebRTC сигналами, игнорируем
*/
return;
}
const signalType = packet.getSignalType();
if(signalType == WebRTCSignalType.ANSWER){
/**
* Другая сторона (сервер SFU) отправил нам SDP ответ на наш оффер
*/
const sdp = JSON.parse(packet.getSdpOrCandidate());
await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp));
if(iceCandidatesBufferRef.current.length > 0){
/**
* У нас есть буферизированные ICE кандидаты, которые мы получили до установки удаленного описания, теперь мы можем их добавить в PeerConnection
*/
for(let i = 0; i < iceCandidatesBufferRef.current.length; i++){
await peerConnectionRef.current?.addIceCandidate(iceCandidatesBufferRef.current[i]);
}
iceCandidatesBufferRef.current = [];
}
info("Received WebRTC answer and set remote description");
return;
}
if(signalType == WebRTCSignalType.ICE_CANDIDATE){
/**
* Другая сторона отправила нам ICE кандидата для установления WebRTC соединения
*/
const candidate = JSON.parse(packet.getSdpOrCandidate());
console.info(candidate);
if(peerConnectionRef.current?.remoteDescription == null){
/**
* Удаленное описание еще не установлено, буферизуем кандидата, чтобы добавить его после установки удаленного описания
*/
iceCandidatesBufferRef.current.push(new RTCIceCandidate(candidate));
info("Received WebRTC ICE candidate but remote description is not set yet, buffering candidate");
return;
}
await peerConnectionRef.current?.addIceCandidate(new RTCIceCandidate(candidate));
info("Received WebRTC ICE candidate and added to peer connection");
return;
}
if(signalType == WebRTCSignalType.OFFER && peerConnectionRef.current){
/**
* SFU сервер отправил нам оффер, например при renegotiation, нам нужно его принять и
* отправить ответ (ANSWER)
*/
const sdp = JSON.parse(packet.getSdpOrCandidate());
await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp));
let answer = await peerConnectionRef.current?.createAnswer();
await peerConnectionRef.current?.setLocalDescription(answer);
let answerSignal = new PacketWebRTC();
answerSignal.setSignalType(WebRTCSignalType.ANSWER);
answerSignal.setSdpOrCandidate(JSON.stringify(answer));
send(answerSignal);
info("Received WebRTC offer, set remote description and sent answer");
return;
}
}, [activeCall, sessionKeys, callState, roomIdRef]);
usePacket(26, async (packet: PacketSignalPeer) => {
const signalType = packet.getSignalType();
if(signalType == SignalType.END_CALL_BECAUSE_BUSY) {
openCallsModal("Line is busy, the user is currently on another call. Please try again later.");
end();
}
if(signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED) {
openCallsModal("The connection with the user was lost. The call has ended.")
end();
}
if(activeCall){
/**
* У нас уже есть активный звонок, игнорируем все сигналы, кроме сигналов от текущего звонка
*/
if(packet.getSrc() != activeCall && packet.getSrc() != publicKey){
console.info("Received signal from " + packet.getSrc() + " but active call is with " + activeCall + ", ignoring");
info("Received signal for another call, ignoring");
return;
}
}
if(signalType == SignalType.END_CALL){
/**
* Сбросили звонок
*/
end();
return;
}
if(signalType == SignalType.CALL){
/**
* Нам поступает звонок
*/
setWindowPriority(true);
playSound("ringtone.mp3", true);
setActiveCall(packet.getSrc());
setCallState(CallState.INCOMING);
setShowCallView(true);
}
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLER){
console.info("EXCHANGE SIGNAL RECEIVED, CALLER ROLE");
/**
* Другая сторона сгенерировала ключи для сессии и отправила нам публичную часть,
* теперь мы можем создать общую секретную сессию для шифрования звонка
*/
const sharedPublic = packet.getSharedPublic();
if(!sharedPublic){
info("Received key exchange signal without shared public key");
return;
}
const sessionKeys = generateSessionKeys();
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
setSharedSecret(Buffer.from(computedSharedSecret).toString('hex'));
info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex'));
/**
* Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию
*/
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(packet.getSrc());
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
send(signalPacket);
setCallState(CallState.WEB_RTC_EXCHANGE);
/**
* Создаем комнату на сервере SFU, комнату создает звонящий
*/
let webRtcSignal = new PacketSignalPeer();
webRtcSignal.setSignalType(SignalType.CREATE_ROOM);
webRtcSignal.setSrc(publicKey);
webRtcSignal.setDst(packet.getSrc());
send(webRtcSignal);
}
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLEE){
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
/**
* Мы отправили свою публичную часть ключа другой стороне,
* теперь мы получили ее публичную часть и можем создать общую
* секретную сессию для шифрования звонка
*/
const sharedPublic = packet.getSharedPublic();
if(!sharedPublic){
info("Received key exchange signal without shared public key");
return;
}
if(!sessionKeys){
info("Received key exchange signal but session keys are not generated");
return;
}
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex'));
setSharedSecret(Buffer.from(computedSharedSecret).toString('hex'));
setCallState(CallState.WEB_RTC_EXCHANGE);
}
if(signalType == SignalType.CREATE_ROOM) {
/**
* Создана комната для обмена WebRTC потоками
*/
roomIdRef.current = packet.getRoomId();
info("WebRTC room created with id: " + packet.getRoomId());
/**
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
*/
peerConnectionRef.current = new RTCPeerConnection({
iceServers: iceServersRef.current
});
/**
* Подписываемся на ICE кандидат
*/
peerConnectionRef.current.onicecandidate = (event) => {
if(event.candidate){
let candidateSignal = new PacketWebRTC();
candidateSignal.setSignalType(WebRTCSignalType.ICE_CANDIDATE);
candidateSignal.setSdpOrCandidate(JSON.stringify(event.candidate));
send(candidateSignal);
}
}
/**
* Соединение установлено, можно начинать звонок, переходим в активное состояние звонка
*/
peerConnectionRef.current.onconnectionstatechange = () => {
console.info("Peer connection state changed: " + peerConnectionRef.current?.connectionState);
if(peerConnectionRef.current?.connectionState == "connected"){
setCallState(CallState.ACTIVE);
info("WebRTC connection established, call is active");
}
}
peerConnectionRef.current.ontrack = (event) => {
/**
* При получении медиа-трека с другой стороны
*/
if(remoteAudioRef.current && event.streams[0]){
console.info(event.streams);
remoteAudioRef.current.srcObject = event.streams[0];
remoteAudioRef.current.muted = !soundRef.current;
void remoteAudioRef.current.play().catch((e) => {
console.error("Failed to play remote audio:", e);
});
}
}
/**
* Запрашиваем Аудио поток с микрофона и добавляем его в PeerConnection, чтобы другая сторона могла его получить и воспроизвести,
* когда мы установим WebRTC соединение
*/
const localStream = await navigator.mediaDevices.getUserMedia({
audio: true
});
localStream.getTracks().forEach(track => {
peerConnectionRef.current?.addTrack(track, localStream);
});
/**
* Отправляем свой оффер другой стороне
*/
let offer = await peerConnectionRef.current.createOffer();
await peerConnectionRef.current.setLocalDescription(offer);
let offerSignal = new PacketWebRTC();
offerSignal.setSignalType(WebRTCSignalType.OFFER);
offerSignal.setSdpOrCandidate(JSON.stringify(offer));
send(offerSignal);
return;
}
}, [activeCall, sessionKeys]);
const openCallsModal = (text : string) => {
modals.open({
centered: true,
children: (
<>
<Text size="sm">
{text}
</Text>
<Flex align={'center'} justify={'flex-end'}>
<Button color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
Close
</Button>
</Flex>
</>
),
withCloseButton: false
});
}
const generateSessionKeys = () => {
const sessionKeys = nacl.box.keyPair();
info("Generated keys for call session, len: " + sessionKeys.publicKey.length);
setSessionKeys(sessionKeys);
return sessionKeys;
}
const call = (dialog: string) => {
if(callState == CallState.ACTIVE
|| callState == CallState.CONNECTING
|| callState == CallState.KEY_EXCHANGE
|| callState == CallState.WEB_RTC_EXCHANGE){
openCallsModal("You are already on a call, please end the current call before starting a new one.");
return;
}
setWindowPriority(false);
setActiveCall(dialog);
setCallState(CallState.CONNECTING);
setShowCallView(true);
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(dialog);
signalPacket.setSignalType(SignalType.CALL);
send(signalPacket);
roleRef.current = CallRole.CALLER;
playSound("calling.mp3", true);
}
const close = () => {
const packetSignal = new PacketSignalPeer();
packetSignal.setSrc(publicKey);
packetSignal.setDst(activeCall);
packetSignal.setSignalType(SignalType.END_CALL);
send(packetSignal);
end();
}
const end = () => {
stopLoopSound();
stopSound();
if (remoteAudioRef.current) {
remoteAudioRef.current.pause();
remoteAudioRef.current.srcObject = null;
}
setDuration(0);
durationIntervalRef.current && clearInterval(durationIntervalRef.current);
setWindowPriority(false);
playSound("end_call.mp3");
peerConnectionRef.current?.close();
peerConnectionRef.current = null;
roomIdRef.current = "";
mutedRef.current = false;
soundRef.current = true;
setActiveCall("");
setCallState(CallState.ENDED);
setShowCallView(false);
setSessionKeys(null);
setDuration(0);
setMutedState(false);
setSoundState(true);
roleRef.current = null;
}
const accept = () => {
if(callState != CallState.INCOMING){
/**
* Нечего принимать
*/
return;
}
setWindowPriority(false);
stopLoopSound();
stopSound();
/**
* Звонок принят, генерируем ключи для сессии и отправляем их другой стороне для установления защищенного канала связи
*/
const keys = generateSessionKeys();
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(activeCall);
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex'));
send(signalPacket);
setCallState(CallState.KEY_EXCHANGE);
roleRef.current = CallRole.CALLEE;
}
/**
* Получает слепок ключа для отображения в UI
* чтобы не показывать настоящий ключ
* @returns
*/
const getKeyCast = () => {
if(!sharedSecret){
return "";
}
return sharedSecret;
}
const setMuted = (nextMuted: boolean) => {
if (mutedRef.current === nextMuted) {
return;
}
mutedRef.current = nextMuted;
playSound(nextMuted ? "micro_enable.mp3" : "micro_disable.mp3");
if(peerConnectionRef.current){
peerConnectionRef.current.getSenders().forEach(sender => {
if(sender.track?.kind == "audio"){
sender.track.enabled = !nextMuted;
}
});
}
setMutedState(nextMuted);
}
const setSound = (nextSound: boolean) => {
if (soundRef.current === nextSound) {
return;
}
soundRef.current = nextSound;
playSound(nextSound ? "sound_enable.mp3" : "sound_disable.mp3");
if(remoteAudioRef.current){
remoteAudioRef.current.muted = !nextSound;
if (nextSound) {
void remoteAudioRef.current.play().catch((e) => {
console.error("Failed to resume remote audio:", e);
});
}
}
setSoundState(nextSound);
}
const context = {
call,
close,
activeCall,
callState,
muted,
sound,
setMuted,
setSound,
duration,
setShowCallView,
getKeyCast,
accept
};
return (
<CallContext.Provider value={context}>
{props.children}
<audio ref={remoteAudioRef} autoPlay playsInline style={{ display: 'none' }} />
{showCallView && <Call context={context}></Call>}
</CallContext.Provider>
)
}

View File

@@ -0,0 +1,5 @@
export const translateDurationToTime = (duration: number) => {
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
}

View File

@@ -0,0 +1,15 @@
import { useContext } from "react";
import { CallContext, CallContextValue } from "./CallProvider";
/**
* Хук предоставляет функции для работы с звонками, такие как инициирование звонка, принятие звонка, завершение звонка и т.д.
* Он может использоваться в компонентах, связанных с звонками, для управления состоянием звонков и взаимодействия с сервером.
*/
export function useCalls() : CallContextValue {
const context = useContext(CallContext);
if (!context) {
throw new Error("useCalls must be used within a CallProvider");
}
return context;
}

View File

@@ -295,7 +295,6 @@ export function DialogProvider(props: DialogProviderProps) {
* Обработчик чтения для личных сообщений
*/
usePacket(0x07, async (packet : PacketRead) => {
info("Read packet received in dialog provider");
const fromPublicKey = packet.getFromPublicKey();
if(fromPublicKey == publicKey){
/**
@@ -309,7 +308,10 @@ export function DialogProvider(props: DialogProviderProps) {
*/
return;
}
if(fromPublicKey != props.dialog && !idle){
if(idle){
return;
}
if(fromPublicKey != props.dialog){
return;
}
setMessages((prev) => prev.map((msg) => {
@@ -342,7 +344,10 @@ export function DialogProvider(props: DialogProviderProps) {
*/
return;
}
if(toPublicKey != props.dialog && !idle){
if(idle){
return;
}
if(toPublicKey != props.dialog){
return;
}
setMessages((prev) => prev.map((msg) => {

View File

@@ -367,13 +367,11 @@ export function useDialogFiber() {
return;
}
await updateSyncTime(Date.now());
console.info("PACKED_READ_IM");
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`, [toPublicKey, fromPublicKey, publicKey]);
console.info("read im with params ", [fromPublicKey, toPublicKey, publicKey]);
updateDialog(fromPublicKey);
log("Read packet received from " + fromPublicKey + " for " + toPublicKey);
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
if (message.from_public_key == toPublicKey && !message.readed) {
if (message.from_public_key == publicKey && !message.readed) {
console.info("Marking message as read in cache for dialog with " + fromPublicKey);
console.info({ fromPublicKey, toPublicKey });
return {

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect } from "react";
import { useContext } from "react";
import { useMemory } from "../MemoryProvider/useMemory";
import { Attachment } from "../ProtocolProvider/protocol/packets/packet.message";
import { DialogContext } from "./DialogProvider";
@@ -35,8 +35,6 @@ export function useReplyMessages() {
const {dialog} = context;
const selectMessage = (message : MessageReply) => {
console.info("-> ", replyMessages);
console.info(message);
if(replyMessages.publicKey != dialog){
/**
* Сброс выбора сообщений из другого диалога
@@ -71,7 +69,6 @@ export function useReplyMessages() {
}
const deselectAllMessages = () => {
console.info("Deselecting all messages");
setReplyMessages({
publicKey: "",
messages: []
@@ -108,16 +105,6 @@ export function useReplyMessages() {
}));
}
useEffect(() => {
if(replyMessages.publicKey != dialog
&& replyMessages.inDialogInput != dialog){
/**
* Сброс выбора сообщений при смене диалога
*/
deselectAllMessages();
}
}, [dialog]);
return {replyMessages,
translateMessagesToDialogInput,
isSelectionInCurrentDialog,

View File

@@ -149,6 +149,7 @@ export function ImageViewer(props : ImageViewerProps) {
userSelect: 'none',
cursor: isDragging ? 'grabbing' : 'grab',
transformOrigin: '0 0',
background: '#FFF',
transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`,
}}
onWheel={onWheel}

View File

@@ -0,0 +1,57 @@
import Packet from "../packet";
import Stream from "../stream";
export interface G365IceServer {
url: string;
username: string;
credential: string;
transport: string;
}
export class PacketIceServers extends Packet {
private iceServers: G365IceServer[] = [];
public getPacketId(): number {
return 28;
}
public _receive(stream: Stream): void {
const serversCount = stream.readInt16();
this.iceServers = [];
for(let i = 0; i < serversCount; i++){
const url = stream.readString();
const username = stream.readString();
const credential = stream.readString();
const transport = stream.readString();
this.iceServers.push({
url,
username,
credential,
transport
});
}
}
public _send(): Promise<Stream> | Stream {
const stream = new Stream();
stream.writeInt16(this.getPacketId());
stream.writeInt16(this.iceServers.length);
for(let i = 0; i < this.iceServers.length; i++){
const server = this.iceServers[i];
stream.writeString(server.url);
stream.writeString(server.username || "");
stream.writeString(server.credential || "");
stream.writeString(server.transport || "");
}
return stream;
}
public getIceServers(): G365IceServer[] {
return this.iceServers;
}
public setIceServers(servers: G365IceServer[]) {
this.iceServers = servers;
}
}

View File

@@ -0,0 +1,116 @@
import Packet from "../packet";
import Stream from "../stream";
export enum SignalType {
CALL = 0,
KEY_EXCHANGE = 1,
ACTIVE_CALL = 2,
END_CALL = 3,
CREATE_ROOM = 4,
END_CALL_BECAUSE_PEER_DISCONNECTED = 5,
END_CALL_BECAUSE_BUSY = 6
}
/**
* Пакет сигналинга, для сигналов WebRTC используется отдельный пакет 27 PacketWebRTCExchange
*/
export class PacketSignalPeer extends Packet {
private src: string = "";
/**
* Назначение
*/
private dst: string = "";
/**
* Используется если SignalType == KEY_EXCHANGE, для идентификации сессии обмена ключами
*/
private sharedPublic: string = "";
private signalType: SignalType = SignalType.CALL;
/**
* Используется если SignalType == CREATE_ROOM,
* для идентификации комнаты на SFU сервере, в которой будет происходить обмен сигналами
* WebRTC для установления P2P соединения между участниками звонка
*/
private roomId: string = "";
public getPacketId(): number {
return 26;
}
public _receive(stream: Stream): void {
this.signalType = stream.readInt8();
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
return;
}
this.src = stream.readString();
this.dst = stream.readString();
if(this.signalType == SignalType.KEY_EXCHANGE){
this.sharedPublic = stream.readString();
}
if(this.signalType == SignalType.CREATE_ROOM){
this.roomId = stream.readString();
}
}
public _send(): Promise<Stream> | Stream {
const stream = new Stream();
stream.writeInt16(this.getPacketId());
stream.writeInt8(this.signalType);
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
return stream;
}
stream.writeString(this.src);
stream.writeString(this.dst);
if(this.signalType == SignalType.KEY_EXCHANGE){
stream.writeString(this.sharedPublic);
}
if(this.signalType == SignalType.CREATE_ROOM){
stream.writeString(this.roomId);
}
return stream;
}
public setDst(dst: string) {
this.dst = dst;
}
public setSharedPublic(sharedPublic: string) {
this.sharedPublic = sharedPublic;
}
public setSignalType(signalType: SignalType) {
this.signalType = signalType;
}
public getDst(): string {
return this.dst;
}
public getSharedPublic(): string {
return this.sharedPublic;
}
public getSignalType(): SignalType {
return this.signalType;
}
public getSrc(): string {
return this.src;
}
public setSrc(src: string) {
this.src = src;
}
public getRoomId(): string {
return this.roomId;
}
public setRoomId(roomId: string) {
this.roomId = roomId;
}
}

View File

@@ -0,0 +1,52 @@
import Packet from "../packet";
import Stream from "../stream";
export enum WebRTCSignalType {
OFFER = 0,
ANSWER = 1,
ICE_CANDIDATE = 2
}
/**
* Пакет для обмена сигналами WebRTC, такими как оффер, ответ и ICE кандидаты.
* Используется на стадии WEB_RTC_EXCHANGE в сигналинге звонков.
*/
export class PacketWebRTC extends Packet {
private signalType: WebRTCSignalType = WebRTCSignalType.OFFER;
private sdpOrCandidate: string = "";
public getPacketId(): number {
return 27;
}
public _receive(stream: Stream): void {
this.signalType = stream.readInt8();
this.sdpOrCandidate = stream.readString();
}
public _send(): Promise<Stream> | Stream {
let stream = new Stream();
stream.writeInt16(this.getPacketId());
stream.writeInt8(this.signalType);
stream.writeString(this.sdpOrCandidate);
return stream;
}
public setSignalType(type: WebRTCSignalType) {
this.signalType = type;
}
public getSignalType(): WebRTCSignalType {
return this.signalType;
}
public setSdpOrCandidate(data: string) {
this.sdpOrCandidate = data;
}
public getSdpOrCandidate(): string {
return this.sdpOrCandidate;
}
}

View File

@@ -25,6 +25,9 @@ import { PacketDeviceNew } from "./packets/packet.device.new";
import { PacketDeviceList } from "./packets/packet.device.list";
import { PacketDeviceResolve } from "./packets/packet.device.resolve";
import { PacketSync } from "./packets/packet.sync";
import { PacketSignalPeer } from "./packets/packet.signal.peer";
import { PacketWebRTC } from "./packets/packet.webrtc";
import { PacketIceServers } from "./packets/packet.ice.servers";
export default class Protocol extends EventEmitter {
private serverAddress: string;
@@ -125,6 +128,9 @@ export default class Protocol extends EventEmitter {
this._supportedPackets.set(0x17, new PacketDeviceList());
this._supportedPackets.set(0x18, new PacketDeviceResolve());
this._supportedPackets.set(25, new PacketSync());
this._supportedPackets.set(26, new PacketSignalPeer());
this._supportedPackets.set(27, new PacketWebRTC());
this._supportedPackets.set(28, new PacketIceServers());
}
private _findWaiters(packetId: number): ((packet: Packet) => void)[] {

View File

@@ -1,7 +1,7 @@
export const SERVERS = [
//'wss://cdn.rosetta-im.com',
//'ws://10.211.55.2:3000',
//'ws://127.0.0.1:3000',
//'ws://192.168.6.82:3000',
'wss://wss.rosetta.im'
];

View File

@@ -1,9 +1,13 @@
export const APP_VERSION = "1.0.8";
export const CORE_MIN_REQUIRED_VERSION = "1.5.0";
export const APP_VERSION = "1.1.0";
export const CORE_MIN_REQUIRED_VERSION = "1.5.2";
export const RELEASE_NOTICE = `
**Обновление v1.0.8** :emoji_1f631:
- Фикс проблемы с загрузкой аватарок в некоторых случаях
- Фикс фонового скролла при увеличении картинки
- Фикс артефактов у картинки
**Обновление v1.1.0** :emoji_1f631:
- Добавлена поддержка звонков
- Прозрачным аватаркам добавлена подложка
- Фикс ошибки чтения
- Подложка к вложению аватарки
- Обмен ключами шифрования DH
- Поддерджка WebRTC
- Событийные звуки звонка (сбросить, мутинг, и прочее...)
`;

View File

@@ -9,12 +9,13 @@ import { useEffect } from "react";
import { useViewPanelsState, ViewPanelsState } from "@/app/hooks/useViewPanelsState";
import { GroupHeader } from "@/app/components/GroupHeader/GroupHeader";
import { useGroups } from "@/app/providers/DialogProvider/useGroups";
import { ActiveCall } from "@/app/components/ActiveCall/ActiveCall";
export function Chat() {
const params = useParams();
const dialog = params.id || "DELETED";
const {lg} = useRosettaBreakpoints();
const [__, setViewState] = useViewPanelsState();
const [viewState, setViewState] = useViewPanelsState();
const {hasGroup} = useGroups();
useEffect(() => {
@@ -30,6 +31,9 @@ export function Chat() {
return (<>
<DialogProvider dialog={dialog} key={dialog}>
<Flex direction={'column'} justify={'space-between'} h={'100%'}>
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && (
<ActiveCall></ActiveCall>
)}
{/* Group Header */}
{hasGroup(dialog) && <GroupHeader></GroupHeader>}
{/* Dialog peer to peer Header */}

View File

@@ -31,6 +31,7 @@ import { useUpdateMessage } from "@/app/hooks/useUpdateMessage";
import { useDeviceMessage } from "@/app/hooks/useDeviceMessage";
import { UpdateProvider } from "@/app/providers/UpdateProvider/UpdateProvider";
import { useSynchronize } from "@/app/providers/DialogProvider/useSynchronize";
import { CallProvider } from "@/app/providers/CallProvider/CallProvider";
export function Main() {
const { mainColor, borderColor } = useRosettaColors();
@@ -154,6 +155,7 @@ export function Main() {
<SystemAccountProvider>
<TransportProvider>
<UpdateProvider>
<CallProvider>
<Flex direction={'row'} style={{
height: '100%',
width: '100vw',
@@ -164,7 +166,9 @@ export function Main() {
}}>
<DialogsPanel></DialogsPanel>
</div>
{lg && (
<Divider color={borderColor} orientation={'vertical'}></Divider>
)}
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && <Box
bg={mainColor}
style={{
@@ -200,6 +204,7 @@ export function Main() {
</Flex>
</Overlay>
)}
</CallProvider>
</UpdateProvider>
</TransportProvider>
</SystemAccountProvider>

View File

@@ -21,7 +21,6 @@ self.onmessage = async (event: MessageEvent) => {
result = await encrypt(payload.data, payload.publicKey);
break;
case 'decrypt':
console.info("decrypt", payload.privateKey, payload.data);
result = await decrypt(payload.data, payload.privateKey);
break;
case 'chacha20Encrypt':

View File

@@ -1,4 +1,4 @@
import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor } from 'electron'
import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor, app } from 'electron'
import { join } from 'path'
import fs from 'fs'
import { WORKING_DIR } from './constants';
@@ -45,7 +45,8 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
nodeIntegrationInSubFrames: true,
nodeIntegrationInWorker: true,
webSecurity: false,
allowRunningInsecureContent: true
allowRunningInsecureContent: true,
autoplayPolicy: 'no-user-gesture-required'
}
});
@@ -73,6 +74,7 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
}
export function foundationIpcRegistration(mainWindow: BrowserWindow) {
let bounceId: number | null = null;
ipcMain.removeAllListeners('window-resize');
ipcMain.removeAllListeners('window-resizeble');
ipcMain.removeAllListeners('window-theme');
@@ -86,6 +88,38 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
ipcMain.removeHandler('window-minimize');
ipcMain.removeHandler('showItemInFolder');
ipcMain.removeHandler('openExternal');
ipcMain.removeHandler('window-top');
ipcMain.removeHandler('window-priority-normal');
ipcMain.handle('window-top', () => {
if (mainWindow.isMinimized()){
mainWindow.restore();
}
mainWindow.setAlwaysOnTop(true, "screen-saver"); // самый высокий уровень
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
mainWindow.show();
mainWindow.focus();
if (process.platform === "darwin") {
/**
* Только в macos! Подпрыгивание иконки в Dock
*/
bounceId = app.dock!.bounce("critical");
}
})
ipcMain.handle('window-priority-normal', () => {
mainWindow.setAlwaysOnTop(false);
mainWindow.setVisibleOnAllWorkspaces(false);
if(process.platform === "darwin" && bounceId !== null){
/**
* Только в macos! Отмена подпрыгивания иконки в Dock
*/
app.dock!.cancelBounce(bounceId);
bounceId = null;
}
})
ipcMain.handle('open-dev-tools', () => {
if (mainWindow.webContents.isDevToolsOpened()) {

View File

@@ -13,5 +13,8 @@ declare global {
downloadsPath: string;
deviceName: string;
deviceId: string;
mediaApi: {
getSoundUrl: (fileName: string) => string;
};
}
}

View File

@@ -1,8 +1,23 @@
import { contextBridge, ipcRenderer, shell } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import api from './api'
import { pathToFileURL } from 'node:url'
import path from 'node:path'
import fs from "node:fs";
function resolveSound(fileName: string) {
const isDev = !process.env.APP_PACKAGED; // или свой флаг dev
const fullPath = isDev
? path.join(process.cwd(), "resources", "sounds", fileName)
: path.join(process.resourcesPath, "resources", "sounds", fileName);
if (!fs.existsSync(fullPath)) {
throw new Error(`Sound not found: ${fullPath}`);
}
return pathToFileURL(fullPath).toString();
}
const exposeContext = async () => {
if (process.contextIsolated) {
try {
@@ -16,6 +31,11 @@ const exposeContext = async () => {
ipcRenderer.invoke('ipcCore:showItemInFolder', fullPath);
}
});
contextBridge.exposeInMainWorld("mediaApi", {
getSoundUrl: (fileName: string) => {
return resolveSound(fileName);
}
});
} catch (error) {
console.error(error)
}
@@ -23,6 +43,11 @@ const exposeContext = async () => {
window.electron = electronAPI
window.api = api;
window.shell = shell;
window.mediaApi = {
getSoundUrl: (fileName: string) => {
return resolveSound(fileName);
}
}
}
}

View File

@@ -1,11 +1,14 @@
{
"name": "Rosetta",
"version": "1.5.0",
"version": "1.5.2",
"description": "Rosetta Messenger",
"main": "./out/main/main.js",
"license": "MIT",
"build": {
"electronUpdaterCompatibility": false,
"extraResources": [
{ "from": "resources/", "to": "resources/" }
],
"files": [
"node_modules/sqlite3/**/*",
"out/main/**/*",
@@ -81,6 +84,8 @@
"@noble/ciphers": "^1.2.1",
"@noble/secp256k1": "^3.0.0",
"@tabler/icons-react": "^3.31.0",
"@types/crypto-js": "^4.2.2",
"@types/diffie-hellman": "^5.0.3",
"@types/elliptic": "^6.4.18",
"@types/node-forge": "^1.3.11",
"@types/npm": "^7.19.3",
@@ -90,7 +95,6 @@
"bip39": "^3.1.0",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"elliptic": "^6.6.1",
@@ -103,9 +107,11 @@
"i": "^0.3.7",
"jsencrypt": "^3.3.2",
"jszip": "^3.10.1",
"libsodium": "^0.8.2",
"lottie-react": "^2.4.1",
"node-forge": "^1.3.1",
"node-machine-id": "^1.1.12",
"npm": "^11.11.0",
"pako": "^2.1.0",
"react-router-dom": "^7.4.0",
"react-syntax-highlighter": "^16.1.0",
@@ -115,6 +121,8 @@
"recharts": "^2.15.1",
"sql.js": "^1.13.0",
"sqlite3": "^5.1.7",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"wa-sqlite": "^1.0.0",
"web-bip39": "^0.0.3"
},
@@ -122,6 +130,7 @@
"@electron-toolkit/eslint-config": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron/rebuild": "^4.0.3",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/vite": "^4.0.9",
"@types/node": "^22.13.5",
@@ -132,7 +141,6 @@
"@vitejs/plugin-react": "^4.3.4",
"electron": "^38.3.0",
"electron-builder": "^25.1.8",
"@electron/rebuild": "^4.0.3",
"electron-vite": "^3.0.0",
"eslint": "^9.21.0",
"eslint-plugin-react": "^7.37.4",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.