Compare commits
31 Commits
e2b767779f
...
523d67b01f
| Author | SHA1 | Date | |
|---|---|---|---|
| 523d67b01f | |||
|
|
8f0e8e8251 | ||
|
|
61d55f266f | ||
|
|
824b1fec65 | ||
|
|
41d7a89830 | ||
|
|
88288317ab | ||
|
|
7b9936dcc4 | ||
|
|
fcf4204063 | ||
|
|
6dd348230f | ||
|
|
2c026d596d | ||
|
|
ab57303eb6 | ||
|
|
f57ec484e3 | ||
|
|
f0d0909382 | ||
|
|
76442c4161 | ||
|
|
0513a90036 | ||
|
|
0600da5b7c | ||
|
|
8dc2537cdc | ||
|
|
2707bd2a39 | ||
|
|
ca36a8d818 | ||
|
|
e79282755b | ||
|
|
e06d58facf | ||
|
|
7a89a3a307 | ||
|
|
9ad0e5d00a | ||
|
|
9eac2fae6f | ||
|
|
461ccbfa94 | ||
|
|
8b16c4ce0f | ||
|
|
c3a53b517e | ||
|
|
b9603462a0 | ||
|
|
84d3cc7be4 | ||
|
|
a431b23476 | ||
|
|
88369171b6 |
@@ -6,9 +6,8 @@ import { ConfirmSeed } from './views/ConfirmSeed/ConfirmSeed';
|
||||
import { SetPassword } from './views/SetPassword/SetPassword';
|
||||
import { Main } from './views/Main/Main';
|
||||
import { ExistsSeed } from './views/ExistsSeed/ExistsSeed';
|
||||
import { Box, Divider } from '@mantine/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import './style.css'
|
||||
import { useRosettaColors } from './hooks/useRosettaColors';
|
||||
import { Buffer } from 'buffer';
|
||||
import { InformationProvider } from './providers/InformationProvider/InformationProvider';
|
||||
import { BlacklistProvider } from './providers/BlacklistProvider/BlacklistProvider';
|
||||
@@ -27,8 +26,6 @@ window.Buffer = Buffer;
|
||||
|
||||
export default function App() {
|
||||
const { allAccounts, accountProviderLoaded } = useAccountProvider();
|
||||
const colors = useRosettaColors();
|
||||
|
||||
|
||||
const getViewByLoginState = () => {
|
||||
if (!accountProviderLoaded) {
|
||||
@@ -59,7 +56,6 @@ export default function App() {
|
||||
<SystemAccountProvider>
|
||||
<Box h={'100%'}>
|
||||
<Topbar></Topbar>
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
<ContextMenuProvider>
|
||||
<ImageViwerProvider>
|
||||
<AvatarProvider>
|
||||
|
||||
@@ -60,6 +60,7 @@ export function ActionAvatar(props : ActionAvatarProps) {
|
||||
size={120}
|
||||
radius={120}
|
||||
mx="auto"
|
||||
bg={'#fff'}
|
||||
name={props.title.trim() || props.publicKey}
|
||||
color={'initials'}
|
||||
src={avatars.length > 0 ?
|
||||
|
||||
42
app/components/ActiveCall/ActiveCall.module.css
Normal file
42
app/components/ActiveCall/ActiveCall.module.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.active {
|
||||
background: linear-gradient(90deg,rgba(0, 186, 59, 1) 0%, rgba(0, 194, 81, 1) 50%);
|
||||
background-size: 200% 200%;
|
||||
animation: activeFlow 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes activeFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
filter: saturate(1.15);
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
}
|
||||
|
||||
.connecting {
|
||||
background: linear-gradient(120deg, #ff2d2d, #ff7a00, #ff2d2d);
|
||||
background-size: 220% 220%;
|
||||
animation: connectingFlow 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes connectingFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
filter: saturate(1.15);
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
filter: saturate(1);
|
||||
}
|
||||
}
|
||||
/* ...existing code... */
|
||||
98
app/components/ActiveCall/ActiveCall.tsx
Normal file
98
app/components/ActiveCall/ActiveCall.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCalls } from "@/app/providers/CallProvider/useCalls";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { Box, Flex, Loader, Text } from "@mantine/core";
|
||||
import classes from "./ActiveCall.module.css";
|
||||
import { CallState } from "@/app/providers/CallProvider/CallProvider";
|
||||
import { IconMicrophone, IconMicrophoneOff, IconPhoneX, IconVolume, IconVolumeOff } from "@tabler/icons-react";
|
||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||
|
||||
export function ActiveCall() {
|
||||
const {activeCall, callState, duration, muted, sound, close, setMuted, setSound, setShowCallView} = useCalls();
|
||||
const [userInfo] = useUserInformation(activeCall);
|
||||
//const colors = useRosettaColors();
|
||||
|
||||
if(activeCall == ""){
|
||||
return <></>
|
||||
}
|
||||
|
||||
const getConnectingClass = () => {
|
||||
if(callState === CallState.CONNECTING
|
||||
|| callState === CallState.INCOMING
|
||||
|| callState === CallState.KEY_EXCHANGE
|
||||
|| callState === CallState.WEB_RTC_EXCHANGE){
|
||||
return classes.connecting;
|
||||
}
|
||||
if(callState === CallState.ACTIVE){
|
||||
return classes.active;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box py={4} style={{
|
||||
cursor: 'pointer'
|
||||
}} px={10} className={getConnectingClass()} onClick={() => setShowCallView(true)}>
|
||||
<Flex align={'center'} justify={'row'} gap={10}>
|
||||
<Flex w={'100%'} justify={'space-between'} align={'center'}>
|
||||
<Flex>
|
||||
{!muted && (
|
||||
<IconMicrophoneOff style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMuted(true);
|
||||
}} size={16} color={'#fff'}></IconMicrophoneOff>
|
||||
)}
|
||||
{muted && (
|
||||
<IconMicrophone style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMuted(false);
|
||||
}} size={16} color={'#fff'}></IconMicrophone>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex justify={'center'} align={'center'} gap={'xs'}>
|
||||
<Text fw={500} c={'#fff'} style={{
|
||||
userSelect: 'none'
|
||||
}} fz={13}>{userInfo?.title || activeCall}</Text>
|
||||
{callState === CallState.CONNECTING && (
|
||||
<Loader type={'dots'} size={12} color="white"></Loader>
|
||||
)}
|
||||
{callState == CallState.ACTIVE && (
|
||||
<Text fw={500} c={'#ffffff'} style={{
|
||||
userSelect: 'none'
|
||||
}} fz={12}>{translateDurationToTime(duration)}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex gap={'xs'} align={'center'} justify={'center'}>
|
||||
{sound && (
|
||||
<IconVolumeOff style={{
|
||||
cursor: 'pointer'
|
||||
}} size={16} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSound(false)
|
||||
}} color={'#fff'}></IconVolumeOff>
|
||||
)}
|
||||
{!sound && (
|
||||
<IconVolume style={{
|
||||
cursor: 'pointer'
|
||||
}} size={16} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSound(true)
|
||||
}} color={'#fff'}></IconVolume>
|
||||
)}
|
||||
<IconPhoneX style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}} size={16} color={'#fff'}></IconPhoneX>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
app/components/Call/Call.tsx
Normal file
139
app/components/Call/Call.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { CallContextValue, CallState } from "@/app/providers/CallProvider/CallProvider";
|
||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { Avatar, Box, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
|
||||
import { IconChevronLeft, IconMicrophone, IconMicrophoneOff, IconPhone, IconPhoneX, IconQrcode, IconVolume, IconVolumeOff, IconX } from "@tabler/icons-react";
|
||||
import { KeyImage } from "../KeyImage/KeyImage";
|
||||
|
||||
export interface CallProps {
|
||||
context: CallContextValue;
|
||||
}
|
||||
|
||||
export function Call(props: CallProps) {
|
||||
const {
|
||||
activeCall,
|
||||
duration,
|
||||
callState,
|
||||
close,
|
||||
sound,
|
||||
setSound,
|
||||
setMuted,
|
||||
setShowCallView,
|
||||
muted,
|
||||
getKeyCast,
|
||||
accept
|
||||
} = props.context;
|
||||
const [userInfo] = useUserInformation(activeCall);
|
||||
const avatars = useAvatars(activeCall);
|
||||
const colors = useRosettaColors();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Box pos={'absolute'} top={0} left={0} w={'100%'} h={'100vh'} style={{
|
||||
zIndex: 11,
|
||||
background: 'linear-gradient(120deg,#141414 0%, #000000 100%)',
|
||||
}}>
|
||||
<Flex h={'100%'} w={'100vw'} direction={'column'} gap={'lg'} pt={'xl'}>
|
||||
<Flex direction={'row'} w={'100%'} gap={'sm'} align={'center'} justify={'space-between'} p={'sm'}>
|
||||
<Flex style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={() => setShowCallView(false)} justify={'center'} align={'center'}>
|
||||
<IconChevronLeft color="white" size={20}></IconChevronLeft>
|
||||
<Text fw={500} c={'white'}>Back</Text>
|
||||
</Flex>
|
||||
<Flex>
|
||||
<Popover width={300} disabled={getKeyCast() == ''} withArrow>
|
||||
<Popover.Target>
|
||||
<IconQrcode color={getKeyCast() == '' ? 'gray' : 'white'} size={24}></IconQrcode>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={'xs'}>
|
||||
<Flex direction={'row'} align={'center'} gap={'xs'}>
|
||||
<Text maw={300} c={'dimmed'} fz={'xs'}>
|
||||
This call is secured by 256 bit end-to-end encryption. Only you and the recipient can read or listen to the content of this call.
|
||||
</Text>
|
||||
<KeyImage radius={0} colors={[
|
||||
theme.colors.blue[1],
|
||||
theme.colors.blue[2],
|
||||
theme.colors.blue[3],
|
||||
theme.colors.blue[4],
|
||||
theme.colors.blue[5]
|
||||
]} size={80} keyRender={getKeyCast()}></KeyImage>
|
||||
</Flex>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex direction={'column'} mt={'xl'} style={{
|
||||
userSelect: 'none'
|
||||
}} w={'100vw'} gap={'sm'} align={'center'} justify={'center'}>
|
||||
<Avatar size={128} bg={avatars.length > 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} color={'initials'} name={userInfo.title}></Avatar>
|
||||
<Text fz={20} fw={'bold'} c={'#FFF'}>{userInfo.title}</Text>
|
||||
{callState == CallState.ACTIVE && (<Text fz={14} c={'#FFF'}>{translateDurationToTime(duration)}</Text>)}
|
||||
{callState == CallState.CONNECTING && (<Text fz={14} c={'#FFF'}>Connecting...</Text>)}
|
||||
{callState == CallState.INCOMING && (<Text fz={14} c={'#FFF'}>Incoming call...</Text>)}
|
||||
{callState == CallState.KEY_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
|
||||
{callState == CallState.WEB_RTC_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
|
||||
<Flex gap={'xl'} align={'center'} justify={'center'} mt={'xl'}>
|
||||
{(callState == CallState.ACTIVE
|
||||
|| callState == CallState.WEB_RTC_EXCHANGE
|
||||
|| callState == CallState.CONNECTING
|
||||
|| callState == CallState.KEY_EXCHANGE) && (
|
||||
<>
|
||||
<Box w={50} onClick={() => setSound(!sound)} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={sound ? colors.chevrons.active : colors.chevrons.disabled}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
{!sound && <IconVolume size={24} color={'#fff'}></IconVolume>}
|
||||
{sound && <IconVolumeOff size={24} color={'#fff'}></IconVolumeOff>}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box w={50} onClick={() => setMuted(!muted)} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={!muted ? colors.chevrons.active : colors.chevrons.disabled}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
{muted && <IconMicrophone size={24} color={'#fff'}></IconMicrophone>}
|
||||
{!muted && <IconMicrophoneOff size={24} color={'#fff'}></IconMicrophoneOff>}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box w={50} onClick={close} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={colors.error}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
<IconPhoneX size={24} color={'#fff'}></IconPhoneX>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{callState == CallState.INCOMING && (
|
||||
<>
|
||||
{userInfo.title != "Rosetta" && (
|
||||
<Box w={50} onClick={close} style={{
|
||||
borderRadius: 25,
|
||||
cursor: 'pointer'
|
||||
}} h={50} bg={colors.error}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
<IconX size={24} color={'#fff'}></IconX>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
<Box w={userInfo.title != "Rosetta" ? 50 : 100} onClick={accept} style={{
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer'
|
||||
}} h={userInfo.title != "Rosetta" ? 50 : 100} bg={colors.success}>
|
||||
<Flex w={'100%'} h={'100%'} justify={'center'} align={'center'}>
|
||||
<IconPhone size={24} color={'#fff'}></IconPhone>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||
import { Avatar, Box, Divider, Flex, Loader, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { Avatar, Box, Divider, Flex, Loader, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconBookmark, IconLockAccess, IconLockCancel, IconTrashX } from "@tabler/icons-react";
|
||||
import { IconBookmark, IconPhone, IconTrashX } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
@@ -20,6 +19,7 @@ import { ReplyHeader } from "../ReplyHeader/ReplyHeader";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { BackToDialogs } from "../BackToDialogs/BackToDialogs";
|
||||
import { useSystemAccounts } from "@/app/providers/SystemAccountsProvider/useSystemAccounts";
|
||||
import { useCalls } from "@/app/providers/CallProvider/useCalls";
|
||||
|
||||
|
||||
export function ChatHeader() {
|
||||
@@ -29,7 +29,6 @@ export function ChatHeader() {
|
||||
const publicKey = usePublicKey();
|
||||
const {deleteMessages, dialog} = useDialog();
|
||||
const theme = useMantineTheme();
|
||||
const [blocked, blockUser, unblockUser] = useBlacklist(dialog);
|
||||
const [opponent, ___, forceUpdateUserInformation] = useUserInformation(dialog);
|
||||
const [protocolState] = useProtocolState();
|
||||
const [userTypeing, setUserTypeing] = useState(false);
|
||||
@@ -39,6 +38,7 @@ export function ChatHeader() {
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const isSystemAccount = systemAccounts.find((acc) => acc.publicKey == dialog) != undefined;
|
||||
const {call} = useCalls();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,20 +78,6 @@ export function ChatHeader() {
|
||||
});
|
||||
}
|
||||
|
||||
const onClickBlockUser = () => {
|
||||
if(opponent.publicKey != "DELETED"
|
||||
&& opponent.publicKey != publicKey){
|
||||
blockUser();
|
||||
}
|
||||
}
|
||||
|
||||
const onClickUnblockUser = () => {
|
||||
if(opponent.publicKey != "DELETED"
|
||||
&& opponent.publicKey != publicKey){
|
||||
unblockUser();
|
||||
}
|
||||
}
|
||||
|
||||
const onClickProfile = () => {
|
||||
if(opponent.publicKey != "DELETED" && opponent.publicKey != publicKey){
|
||||
navigate("/main/profile/" + opponent.publicKey);
|
||||
@@ -116,7 +102,7 @@ export function ChatHeader() {
|
||||
onClick={onClickProfile}
|
||||
>
|
||||
<IconBookmark stroke={2} size={20}></IconBookmark>
|
||||
</Avatar> : <Avatar onClick={onClickProfile} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
|
||||
</Avatar> : <Avatar onClick={onClickProfile} bg={avatars.length > 0 ? '#fff' : undefined} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
|
||||
}
|
||||
<Flex direction={'column'} onClick={onClickProfile}>
|
||||
<Flex align={'center'} gap={3}>
|
||||
@@ -149,32 +135,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>}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -10,6 +10,8 @@ import { DialogsPanelHeader } from '../DialogsPanelHeader/DialogsPanelHeader';
|
||||
import { useDialogsList } from '@/app/providers/DialogListProvider/useDialogsList';
|
||||
import { useVerifyRequest } from '@/app/providers/DeviceProvider/useVerifyRequest';
|
||||
import { DeviceVerify } from '../DeviceVerify/DeviceVerify';
|
||||
import { ActiveCall } from '../ActiveCall/ActiveCall';
|
||||
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
|
||||
|
||||
export function DialogsPanel() {
|
||||
const [dialogsMode, setDialogsMode] = useState<'all' | 'requests'>('all');
|
||||
@@ -18,6 +20,7 @@ export function DialogsPanel() {
|
||||
const colors = useRosettaColors();
|
||||
const navigate = useNavigate();
|
||||
const device = useVerifyRequest();
|
||||
const [viewState] = useViewPanelsState();
|
||||
|
||||
useEffect(() => {
|
||||
((async () => {
|
||||
@@ -52,6 +55,9 @@ export function DialogsPanel() {
|
||||
direction={'column'}
|
||||
justify={'space-between'}
|
||||
>
|
||||
{viewState == ViewPanelsState.DIALOGS_PANEL_ONLY && (
|
||||
<ActiveCall></ActiveCall>
|
||||
)}
|
||||
<Box>
|
||||
<DialogsPanelHeader></DialogsPanelHeader>
|
||||
{device && (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
left: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
z-index: 15;
|
||||
app-region: no-drag;
|
||||
}
|
||||
.close_btn, .minimize_btn, .maximize_btn {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function MentionRow(props : MentionRowProps) {
|
||||
name={props.title}
|
||||
variant="light"
|
||||
color="initials"
|
||||
bg={avatars.length > 0 ? '#fff' : undefined}
|
||||
src={avatars.length > 0 ? avatars[0].avatar : null}
|
||||
></Avatar>}
|
||||
<Flex direction={'column'}>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -40,6 +40,7 @@ export function UserRow(props: UserRowProps) {
|
||||
radius="xl"
|
||||
name={userInfo.title}
|
||||
color={'initials'}
|
||||
bg={avatars.length > 0 ? '#fff' : undefined}
|
||||
src={avatars.length > 0 ? avatars[0].avatar : undefined}
|
||||
/>
|
||||
<Flex direction={'column'}>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.global = window;
|
||||
</script>
|
||||
<script type="module" src="/renderer.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
78
app/hooks/useSound.ts
Normal file
78
app/hooks/useSound.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
export function useSound() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const loopingAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const stopSound = () => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.removeAttribute("src");
|
||||
audioRef.current.load();
|
||||
};
|
||||
|
||||
const playSound = (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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
541
app/providers/CallProvider/CallProvider.tsx
Normal file
541
app/providers/CallProvider/CallProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
app/providers/CallProvider/translateDurationTime.ts
Normal file
5
app/providers/CallProvider/translateDurationTime.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const translateDurationToTime = (duration: number) => {
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
|
||||
}
|
||||
15
app/providers/CallProvider/useCalls.ts
Normal file
15
app/providers/CallProvider/useCalls.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useContext } from "react";
|
||||
import { CallContext, CallContextValue } from "./CallProvider";
|
||||
|
||||
/**
|
||||
* Хук предоставляет функции для работы с звонками, такие как инициирование звонка, принятие звонка, завершение звонка и т.д.
|
||||
* Он может использоваться в компонентах, связанных с звонками, для управления состоянием звонков и взаимодействия с сервером.
|
||||
*/
|
||||
export function useCalls() : CallContextValue {
|
||||
const context = useContext(CallContext);
|
||||
if (!context) {
|
||||
throw new Error("useCalls must be used within a CallProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum SignalType {
|
||||
CALL = 0,
|
||||
KEY_EXCHANGE = 1,
|
||||
ACTIVE_CALL = 2,
|
||||
END_CALL = 3,
|
||||
CREATE_ROOM = 4,
|
||||
END_CALL_BECAUSE_PEER_DISCONNECTED = 5,
|
||||
END_CALL_BECAUSE_BUSY = 6
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакет сигналинга, для сигналов WebRTC используется отдельный пакет 27 PacketWebRTCExchange
|
||||
*/
|
||||
export class PacketSignalPeer extends Packet {
|
||||
|
||||
private src: string = "";
|
||||
/**
|
||||
* Назначение
|
||||
*/
|
||||
private dst: string = "";
|
||||
/**
|
||||
* Используется если SignalType == KEY_EXCHANGE, для идентификации сессии обмена ключами
|
||||
*/
|
||||
private sharedPublic: string = "";
|
||||
|
||||
private signalType: SignalType = SignalType.CALL;
|
||||
|
||||
/**
|
||||
* Используется если SignalType == CREATE_ROOM,
|
||||
* для идентификации комнаты на SFU сервере, в которой будет происходить обмен сигналами
|
||||
* WebRTC для установления P2P соединения между участниками звонка
|
||||
*/
|
||||
private roomId: string = "";
|
||||
|
||||
|
||||
public getPacketId(): number {
|
||||
return 26;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.signalType = stream.readInt8();
|
||||
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
|
||||
return;
|
||||
}
|
||||
this.src = stream.readString();
|
||||
this.dst = stream.readString();
|
||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||
this.sharedPublic = stream.readString();
|
||||
}
|
||||
if(this.signalType == SignalType.CREATE_ROOM){
|
||||
this.roomId = stream.readString();
|
||||
}
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeInt8(this.signalType);
|
||||
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY || this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
|
||||
return stream;
|
||||
}
|
||||
stream.writeString(this.src);
|
||||
stream.writeString(this.dst);
|
||||
if(this.signalType == SignalType.KEY_EXCHANGE){
|
||||
stream.writeString(this.sharedPublic);
|
||||
}
|
||||
if(this.signalType == SignalType.CREATE_ROOM){
|
||||
stream.writeString(this.roomId);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setDst(dst: string) {
|
||||
this.dst = dst;
|
||||
}
|
||||
|
||||
public setSharedPublic(sharedPublic: string) {
|
||||
this.sharedPublic = sharedPublic;
|
||||
}
|
||||
|
||||
public setSignalType(signalType: SignalType) {
|
||||
this.signalType = signalType;
|
||||
}
|
||||
|
||||
public getDst(): string {
|
||||
return this.dst;
|
||||
}
|
||||
|
||||
public getSharedPublic(): string {
|
||||
return this.sharedPublic;
|
||||
}
|
||||
|
||||
public getSignalType(): SignalType {
|
||||
return this.signalType;
|
||||
}
|
||||
|
||||
public getSrc(): string {
|
||||
return this.src;
|
||||
}
|
||||
|
||||
public setSrc(src: string) {
|
||||
this.src = src;
|
||||
}
|
||||
|
||||
public getRoomId(): string {
|
||||
return this.roomId;
|
||||
}
|
||||
|
||||
public setRoomId(roomId: string) {
|
||||
this.roomId = roomId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum WebRTCSignalType {
|
||||
OFFER = 0,
|
||||
ANSWER = 1,
|
||||
ICE_CANDIDATE = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакет для обмена сигналами WebRTC, такими как оффер, ответ и ICE кандидаты.
|
||||
* Используется на стадии WEB_RTC_EXCHANGE в сигналинге звонков.
|
||||
*/
|
||||
export class PacketWebRTC extends Packet {
|
||||
|
||||
private signalType: WebRTCSignalType = WebRTCSignalType.OFFER;
|
||||
private sdpOrCandidate: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 27;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.signalType = stream.readInt8();
|
||||
this.sdpOrCandidate = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
let stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeInt8(this.signalType);
|
||||
stream.writeString(this.sdpOrCandidate);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setSignalType(type: WebRTCSignalType) {
|
||||
this.signalType = type;
|
||||
}
|
||||
|
||||
public getSignalType(): WebRTCSignalType {
|
||||
return this.signalType;
|
||||
}
|
||||
|
||||
public setSdpOrCandidate(data: string) {
|
||||
this.sdpOrCandidate = data;
|
||||
}
|
||||
|
||||
public getSdpOrCandidate(): string {
|
||||
return this.sdpOrCandidate;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,9 @@ import { PacketDeviceNew } from "./packets/packet.device.new";
|
||||
import { PacketDeviceList } from "./packets/packet.device.list";
|
||||
import { PacketDeviceResolve } from "./packets/packet.device.resolve";
|
||||
import { PacketSync } from "./packets/packet.sync";
|
||||
import { PacketSignalPeer } from "./packets/packet.signal.peer";
|
||||
import { PacketWebRTC } from "./packets/packet.webrtc";
|
||||
import { PacketIceServers } from "./packets/packet.ice.servers";
|
||||
|
||||
export default class Protocol extends EventEmitter {
|
||||
private serverAddress: string;
|
||||
@@ -125,6 +128,9 @@ export default class Protocol extends EventEmitter {
|
||||
this._supportedPackets.set(0x17, new PacketDeviceList());
|
||||
this._supportedPackets.set(0x18, new PacketDeviceResolve());
|
||||
this._supportedPackets.set(25, new PacketSync());
|
||||
this._supportedPackets.set(26, new PacketSignalPeer());
|
||||
this._supportedPackets.set(27, new PacketWebRTC());
|
||||
this._supportedPackets.set(28, new PacketIceServers());
|
||||
}
|
||||
|
||||
private _findWaiters(packetId: number): ((packet: Packet) => void)[] {
|
||||
|
||||
@@ -1,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'
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
- Событийные звуки звонка (сбросить, мутинг, и прочее...)
|
||||
`;
|
||||
@@ -9,12 +9,13 @@ import { useEffect } from "react";
|
||||
import { useViewPanelsState, ViewPanelsState } from "@/app/hooks/useViewPanelsState";
|
||||
import { GroupHeader } from "@/app/components/GroupHeader/GroupHeader";
|
||||
import { useGroups } from "@/app/providers/DialogProvider/useGroups";
|
||||
import { ActiveCall } from "@/app/components/ActiveCall/ActiveCall";
|
||||
|
||||
export function Chat() {
|
||||
const params = useParams();
|
||||
const dialog = params.id || "DELETED";
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const [__, setViewState] = useViewPanelsState();
|
||||
const [viewState, setViewState] = useViewPanelsState();
|
||||
const {hasGroup} = useGroups();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,6 +31,9 @@ export function Chat() {
|
||||
return (<>
|
||||
<DialogProvider dialog={dialog} key={dialog}>
|
||||
<Flex direction={'column'} justify={'space-between'} h={'100%'}>
|
||||
{viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && (
|
||||
<ActiveCall></ActiveCall>
|
||||
)}
|
||||
{/* Group Header */}
|
||||
{hasGroup(dialog) && <GroupHeader></GroupHeader>}
|
||||
{/* Dialog peer to peer Header */}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useUpdateMessage } from "@/app/hooks/useUpdateMessage";
|
||||
import { useDeviceMessage } from "@/app/hooks/useDeviceMessage";
|
||||
import { UpdateProvider } from "@/app/providers/UpdateProvider/UpdateProvider";
|
||||
import { useSynchronize } from "@/app/providers/DialogProvider/useSynchronize";
|
||||
import { CallProvider } from "@/app/providers/CallProvider/CallProvider";
|
||||
|
||||
export function Main() {
|
||||
const { mainColor, borderColor } = useRosettaColors();
|
||||
@@ -154,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>
|
||||
|
||||
@@ -21,7 +21,6 @@ self.onmessage = async (event: MessageEvent) => {
|
||||
result = await encrypt(payload.data, payload.publicKey);
|
||||
break;
|
||||
case 'decrypt':
|
||||
console.info("decrypt", payload.privateKey, payload.data);
|
||||
result = await decrypt(payload.data, payload.privateKey);
|
||||
break;
|
||||
case 'chacha20Encrypt':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor } from 'electron'
|
||||
import { BrowserWindow, shell, ipcMain, nativeTheme, screen, powerMonitor, app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import fs from 'fs'
|
||||
import { WORKING_DIR } from './constants';
|
||||
@@ -45,7 +45,8 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
||||
nodeIntegrationInSubFrames: true,
|
||||
nodeIntegrationInWorker: true,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: true
|
||||
allowRunningInsecureContent: true,
|
||||
autoplayPolicy: 'no-user-gesture-required'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,6 +74,7 @@ export function createAppWindow(preloaderWindow?: BrowserWindow): void {
|
||||
}
|
||||
|
||||
export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
||||
let bounceId: number | null = null;
|
||||
ipcMain.removeAllListeners('window-resize');
|
||||
ipcMain.removeAllListeners('window-resizeble');
|
||||
ipcMain.removeAllListeners('window-theme');
|
||||
@@ -86,6 +88,38 @@ export function foundationIpcRegistration(mainWindow: BrowserWindow) {
|
||||
ipcMain.removeHandler('window-minimize');
|
||||
ipcMain.removeHandler('showItemInFolder');
|
||||
ipcMain.removeHandler('openExternal');
|
||||
ipcMain.removeHandler('window-top');
|
||||
ipcMain.removeHandler('window-priority-normal');
|
||||
|
||||
ipcMain.handle('window-top', () => {
|
||||
if (mainWindow.isMinimized()){
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.setAlwaysOnTop(true, "screen-saver"); // самый высокий уровень
|
||||
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
/**
|
||||
* Только в macos! Подпрыгивание иконки в Dock
|
||||
*/
|
||||
bounceId = app.dock!.bounce("critical");
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('window-priority-normal', () => {
|
||||
mainWindow.setAlwaysOnTop(false);
|
||||
mainWindow.setVisibleOnAllWorkspaces(false);
|
||||
if(process.platform === "darwin" && bounceId !== null){
|
||||
/**
|
||||
* Только в macos! Отмена подпрыгивания иконки в Dock
|
||||
*/
|
||||
app.dock!.cancelBounce(bounceId);
|
||||
bounceId = null;
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('open-dev-tools', () => {
|
||||
if (mainWindow.webContents.isDevToolsOpened()) {
|
||||
|
||||
3
lib/preload/index.d.ts
vendored
3
lib/preload/index.d.ts
vendored
@@ -13,5 +13,8 @@ declare global {
|
||||
downloadsPath: string;
|
||||
deviceName: string;
|
||||
deviceId: string;
|
||||
mediaApi: {
|
||||
getSoundUrl: (fileName: string) => string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
package.json
14
package.json
@@ -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",
|
||||
|
||||
BIN
resources/sounds/calling.mp3
Normal file
BIN
resources/sounds/calling.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/connected.mp3
Normal file
BIN
resources/sounds/connected.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/end_call.mp3
Normal file
BIN
resources/sounds/end_call.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/micro_disable.mp3
Normal file
BIN
resources/sounds/micro_disable.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/micro_enable.mp3
Normal file
BIN
resources/sounds/micro_enable.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/ringtone.mp3
Normal file
BIN
resources/sounds/ringtone.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/sound_disable.mp3
Normal file
BIN
resources/sounds/sound_disable.mp3
Normal file
Binary file not shown.
BIN
resources/sounds/sound_enable.mp3
Normal file
BIN
resources/sounds/sound_enable.mp3
Normal file
Binary file not shown.
Reference in New Issue
Block a user