diff --git a/app/App.tsx b/app/App.tsx index 5087969..5d0d7cf 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -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() { - diff --git a/app/components/ActionAvatar/ActionAvatar.tsx b/app/components/ActionAvatar/ActionAvatar.tsx index f328e7f..d819f77 100644 --- a/app/components/ActionAvatar/ActionAvatar.tsx +++ b/app/components/ActionAvatar/ActionAvatar.tsx @@ -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 ? diff --git a/app/components/ActiveCall/ActiveCall.module.css b/app/components/ActiveCall/ActiveCall.module.css new file mode 100644 index 0000000..51b0a41 --- /dev/null +++ b/app/components/ActiveCall/ActiveCall.module.css @@ -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... */ \ No newline at end of file diff --git a/app/components/ActiveCall/ActiveCall.tsx b/app/components/ActiveCall/ActiveCall.tsx new file mode 100644 index 0000000..b3cbdb3 --- /dev/null +++ b/app/components/ActiveCall/ActiveCall.tsx @@ -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 ( + <> + setShowCallView(true)}> + + + + {!muted && ( + { + e.stopPropagation(); + setMuted(true); + }} size={16} color={'#fff'}> + )} + {muted && ( + { + e.stopPropagation(); + setMuted(false); + }} size={16} color={'#fff'}> + )} + + + {userInfo?.title || activeCall} + {callState === CallState.CONNECTING && ( + + )} + {callState == CallState.ACTIVE && ( + {translateDurationToTime(duration)} + )} + + + {sound && ( + { + e.stopPropagation(); + setSound(false) + }} color={'#fff'}> + )} + {!sound && ( + { + e.stopPropagation(); + setSound(true) + }} color={'#fff'}> + )} + { + e.stopPropagation(); + close(); + }} size={16} color={'#fff'}> + + + + + + ); +} \ No newline at end of file diff --git a/app/components/Call/Call.tsx b/app/components/Call/Call.tsx new file mode 100644 index 0000000..76a8908 --- /dev/null +++ b/app/components/Call/Call.tsx @@ -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 ( + + + + setShowCallView(false)} justify={'center'} align={'center'}> + + Back + + + + + + + + + + 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. + + + + + + + + + 0 ? '#fff' : undefined} src={avatars.length > 0 ? avatars[0].avatar : undefined} color={'initials'} name={userInfo.title}> + {userInfo.title} + {callState == CallState.ACTIVE && ({translateDurationToTime(duration)})} + {callState == CallState.CONNECTING && (Connecting...)} + {callState == CallState.INCOMING && (Incoming call...)} + {callState == CallState.KEY_EXCHANGE && (Exchanging encryption keys...)} + {callState == CallState.WEB_RTC_EXCHANGE && (Exchanging encryption keys...)} + + {(callState == CallState.ACTIVE + || callState == CallState.WEB_RTC_EXCHANGE + || callState == CallState.CONNECTING + || callState == CallState.KEY_EXCHANGE) && ( + <> + setSound(!sound)} style={{ + borderRadius: 25, + cursor: 'pointer' + }} h={50} bg={sound ? colors.chevrons.active : colors.chevrons.disabled}> + + {!sound && } + {sound && } + + + setMuted(!muted)} style={{ + borderRadius: 25, + cursor: 'pointer' + }} h={50} bg={!muted ? colors.chevrons.active : colors.chevrons.disabled}> + + {muted && } + {!muted && } + + + + + + + + + )} + {callState == CallState.INCOMING && ( + <> + {userInfo.title != "Rosetta" && ( + + + + + + )} + + + + + + + )} + + + + + ) +} \ No newline at end of file diff --git a/app/components/ChatHeader/ChatHeader.tsx b/app/components/ChatHeader/ChatHeader.tsx index 0b13363..f14023e 100644 --- a/app/components/ChatHeader/ChatHeader.tsx +++ b/app/components/ChatHeader/ChatHeader.tsx @@ -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} > - : 0 ? avatars[0].avatar : undefined} name={opponent.title}> + : 0 ? '#fff' : undefined} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}> } @@ -149,32 +135,16 @@ export function ChatHeader() { - - - - {publicKey != opponent.publicKey && !blocked && !isSystemAccount && ( - - - - - )} - {blocked && !isSystemAccount && ( - - - - - )} + call(dialog)} + style={{ + cursor: 'pointer' + }} stroke={1.5} color={theme.colors.blue[7]} size={24}> + } {replyMessages.messages.length > 0 && !replyMessages.inDialogInput && } diff --git a/app/components/Dialog/Dialog.tsx b/app/components/Dialog/Dialog.tsx index 9c4137c..450d759 100644 --- a/app/components/Dialog/Dialog.tsx +++ b/app/components/Dialog/Dialog.tsx @@ -88,7 +88,7 @@ export function Dialog(props : DialogProps) { : - 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} /> + 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 && ( ('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 && ( + + )} {device && ( diff --git a/app/components/MacFrameButtons/MacFrameButtons.module.css b/app/components/MacFrameButtons/MacFrameButtons.module.css index b00a813..ef4bb89 100644 --- a/app/components/MacFrameButtons/MacFrameButtons.module.css +++ b/app/components/MacFrameButtons/MacFrameButtons.module.css @@ -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 { diff --git a/app/components/MentionList/MentionRow.tsx b/app/components/MentionList/MentionRow.tsx index 742be56..922dbf7 100644 --- a/app/components/MentionList/MentionRow.tsx +++ b/app/components/MentionList/MentionRow.tsx @@ -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} >} diff --git a/app/components/MessageAttachments/MessageAvatar.tsx b/app/components/MessageAttachments/MessageAvatar.tsx index 561cb21..53d19aa 100644 --- a/app/components/MessageAttachments/MessageAvatar.tsx +++ b/app/components/MessageAttachments/MessageAvatar.tsx @@ -78,7 +78,8 @@ export function MessageAvatar(props: AttachmentProps) { height: 60, width: 60, borderRadius: '50%', - objectFit: 'cover' + objectFit: 'cover', + background: '#fff' }} src={blob}>)} {downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING && preview.length >= 20 && ( <> diff --git a/app/components/Messages/Message.tsx b/app/components/Messages/Message.tsx index 9e21c66..f61d620 100644 --- a/app/components/Messages/Message.tsx +++ b/app/components/Messages/Message.tsx @@ -186,7 +186,7 @@ export function Message(props: MessageProps) { {computedMessageStyle == MessageStyle.ROWS && ( - {(!props.avatar_no_render && (md || !props.replyed)) && 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials">} + {(!props.avatar_no_render && (md || !props.replyed)) && 0 ? '#fff' : undefined} onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials">} {!props.avatar_no_render && ( @@ -262,7 +262,7 @@ export function Message(props: MessageProps) { return ( {(md && props.is_last_message_in_stack) && ( - 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}> + 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 }}> )} {(md && !props.is_last_message_in_stack) && ( diff --git a/app/components/UserRow/UserRow.tsx b/app/components/UserRow/UserRow.tsx index dec80b8..585679b 100644 --- a/app/components/UserRow/UserRow.tsx +++ b/app/components/UserRow/UserRow.tsx @@ -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} /> diff --git a/app/dev.html b/app/dev.html index 969a459..dd03527 100644 --- a/app/dev.html +++ b/app/dev.html @@ -6,6 +6,9 @@
+ diff --git a/app/hooks/useSound.ts b/app/hooks/useSound.ts new file mode 100644 index 0000000..44ffac8 --- /dev/null +++ b/app/hooks/useSound.ts @@ -0,0 +1,78 @@ +import { useRef } from "react"; + +export function useSound() { + const audioRef = useRef(null); + const loopingAudioRef = useRef(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 + } +} \ No newline at end of file diff --git a/app/hooks/useWindow.ts b/app/hooks/useWindow.ts index ea5d28f..cd3ff31 100644 --- a/app/hooks/useWindow.ts +++ b/app/hooks/useWindow.ts @@ -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 } } diff --git a/app/providers/CallProvider/CallProvider.tsx b/app/providers/CallProvider/CallProvider.tsx new file mode 100644 index 0000000..36202f1 --- /dev/null +++ b/app/providers/CallProvider/CallProvider.tsx @@ -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(null); +export interface CallProviderProps { + children: React.ReactNode; +} + +export function CallProvider(props : CallProviderProps) { + const [activeCall, setActiveCall] = useState(""); + const [callState, setCallState] = useState(CallState.ENDED); + const [muted, setMutedState] = useState(false); + const [sound, setSoundState] = useState(true); + const durationIntervalRef = useRef(null); + const [duration, setDuration] = useState(0); + const [showCallView, setShowCallView] = useState(callState == CallState.INCOMING); + const {info} = useConsoleLogger("CallProvider"); + const [sessionKeys, setSessionKeys] = useState(null); + const send = useSender(); + const publicKey = usePublicKey(); + const peerConnectionRef = useRef(null); + const roomIdRef = useRef(""); + + const roleRef = useRef(null); + const [sharedSecret, setSharedSecret] = useState(""); + const iceServersRef = useRef([]); + const remoteAudioRef = useRef(null); + const iceCandidatesBufferRef = useRef([]); + const mutedRef = useRef(false); + const soundRef = useRef(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} + + + + + + ), + 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 ( + + {props.children} + + ) +} \ No newline at end of file diff --git a/app/providers/CallProvider/translateDurationTime.ts b/app/providers/CallProvider/translateDurationTime.ts new file mode 100644 index 0000000..fe27634 --- /dev/null +++ b/app/providers/CallProvider/translateDurationTime.ts @@ -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}`; +} \ No newline at end of file diff --git a/app/providers/CallProvider/useCalls.ts b/app/providers/CallProvider/useCalls.ts new file mode 100644 index 0000000..45e3aea --- /dev/null +++ b/app/providers/CallProvider/useCalls.ts @@ -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; +} \ No newline at end of file diff --git a/app/providers/DialogProvider/DialogProvider.tsx b/app/providers/DialogProvider/DialogProvider.tsx index 377e27d..f255ea2 100644 --- a/app/providers/DialogProvider/DialogProvider.tsx +++ b/app/providers/DialogProvider/DialogProvider.tsx @@ -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) => { diff --git a/app/providers/DialogProvider/useDialogFiber.ts b/app/providers/DialogProvider/useDialogFiber.ts index c2399ce..ebcf58d 100644 --- a/app/providers/DialogProvider/useDialogFiber.ts +++ b/app/providers/DialogProvider/useDialogFiber.ts @@ -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 { diff --git a/app/providers/DialogProvider/useReplyMessages.ts b/app/providers/DialogProvider/useReplyMessages.ts index 487e8bb..6083fdf 100644 --- a/app/providers/DialogProvider/useReplyMessages.ts +++ b/app/providers/DialogProvider/useReplyMessages.ts @@ -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, diff --git a/app/providers/ImageViewerProvider/ImageViewer.tsx b/app/providers/ImageViewerProvider/ImageViewer.tsx index b4b5931..9454fd0 100644 --- a/app/providers/ImageViewerProvider/ImageViewer.tsx +++ b/app/providers/ImageViewerProvider/ImageViewer.tsx @@ -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} diff --git a/app/providers/ProtocolProvider/protocol/packets/packet.ice.servers.ts b/app/providers/ProtocolProvider/protocol/packets/packet.ice.servers.ts new file mode 100644 index 0000000..2a8bacd --- /dev/null +++ b/app/providers/ProtocolProvider/protocol/packets/packet.ice.servers.ts @@ -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 { + 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; + } + +} \ No newline at end of file diff --git a/app/providers/ProtocolProvider/protocol/packets/packet.signal.peer.ts b/app/providers/ProtocolProvider/protocol/packets/packet.signal.peer.ts new file mode 100644 index 0000000..1ad3d38 --- /dev/null +++ b/app/providers/ProtocolProvider/protocol/packets/packet.signal.peer.ts @@ -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 { + 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; + } + +} \ No newline at end of file diff --git a/app/providers/ProtocolProvider/protocol/packets/packet.webrtc.ts b/app/providers/ProtocolProvider/protocol/packets/packet.webrtc.ts new file mode 100644 index 0000000..9a5ca74 --- /dev/null +++ b/app/providers/ProtocolProvider/protocol/packets/packet.webrtc.ts @@ -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 { + 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; + } + +} \ No newline at end of file diff --git a/app/providers/ProtocolProvider/protocol/protocol.ts b/app/providers/ProtocolProvider/protocol/protocol.ts index 2189a6a..c9e6822 100644 --- a/app/providers/ProtocolProvider/protocol/protocol.ts +++ b/app/providers/ProtocolProvider/protocol/protocol.ts @@ -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)[] { diff --git a/app/servers.ts b/app/servers.ts index aa52cbb..c5779c3 100644 --- a/app/servers.ts +++ b/app/servers.ts @@ -1,10 +1,10 @@ export const SERVERS = [ //'wss://cdn.rosetta-im.com', //'ws://10.211.55.2:3000', - //'ws://127.0.0.1:3000', - 'wss://wss.rosetta.im' + //'ws://192.168.6.82:3000', + 'wss://wss.rosetta.im' ]; - + export function selectServer(): string { const idx = Math.floor(Math.random() * SERVERS.length); return SERVERS[idx]; diff --git a/app/version.ts b/app/version.ts index da72aec..92dfb77 100644 --- a/app/version.ts +++ b/app/version.ts @@ -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 +- Событийные звуки звонка (сбросить, мутинг, и прочее...) `; \ No newline at end of file diff --git a/app/views/Chat/Chat.tsx b/app/views/Chat/Chat.tsx index 5196d8e..6e393f5 100644 --- a/app/views/Chat/Chat.tsx +++ b/app/views/Chat/Chat.tsx @@ -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 (<> + {viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && ( + + )} {/* Group Header */} {hasGroup(dialog) && } {/* Dialog peer to peer Header */} diff --git a/app/views/Main/Main.tsx b/app/views/Main/Main.tsx index fdb45d1..847bb6d 100644 --- a/app/views/Main/Main.tsx +++ b/app/views/Main/Main.tsx @@ -31,6 +31,7 @@ import { useUpdateMessage } from "@/app/hooks/useUpdateMessage"; import { useDeviceMessage } from "@/app/hooks/useDeviceMessage"; import { UpdateProvider } from "@/app/providers/UpdateProvider/UpdateProvider"; import { useSynchronize } from "@/app/providers/DialogProvider/useSynchronize"; +import { CallProvider } from "@/app/providers/CallProvider/CallProvider"; export function Main() { const { mainColor, borderColor } = useRosettaColors(); @@ -154,52 +155,56 @@ export function Main() { - -
+ - -
- - {viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && - - }> - }> - }> - }> - }> - }> - }> - }> - }> - }> - }> - - } -
- {oldPublicKey && ( - - - - Your account uses an old format public key which is no longer supported. Please create a new account to continue using the application. -

After press "OK" button, the application will close and remove all data. -
- -
-
- )} +
+ +
+ {lg && ( + + )} + {viewState != ViewPanelsState.DIALOGS_PANEL_ONLY && + + }> + }> + }> + }> + }> + }> + }> + }> + }> + }> + }> + + } +
+ {oldPublicKey && ( + + + + Your account uses an old format public key which is no longer supported. Please create a new account to continue using the application. +

After press "OK" button, the application will close and remove all data. +
+ +
+
+ )} +
diff --git a/app/workers/crypto/crypto.worker.ts b/app/workers/crypto/crypto.worker.ts index 6765fdf..12d1dcf 100644 --- a/app/workers/crypto/crypto.worker.ts +++ b/app/workers/crypto/crypto.worker.ts @@ -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': diff --git a/lib/main/app.ts b/lib/main/app.ts index 04ea6eb..38461f7 100644 --- a/lib/main/app.ts +++ b/lib/main/app.ts @@ -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()) { diff --git a/lib/preload/index.d.ts b/lib/preload/index.d.ts index 35fa486..284361b 100644 --- a/lib/preload/index.d.ts +++ b/lib/preload/index.d.ts @@ -13,5 +13,8 @@ declare global { downloadsPath: string; deviceName: string; deviceId: string; + mediaApi: { + getSoundUrl: (fileName: string) => string; + }; } } diff --git a/lib/preload/preload.ts b/lib/preload/preload.ts index ce67cae..dbe848a 100644 --- a/lib/preload/preload.ts +++ b/lib/preload/preload.ts @@ -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); + } + } } } diff --git a/package.json b/package.json index 153dc92..985f4ec 100644 --- a/package.json +++ b/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", diff --git a/resources/sounds/calling.mp3 b/resources/sounds/calling.mp3 new file mode 100644 index 0000000..5a00cd0 Binary files /dev/null and b/resources/sounds/calling.mp3 differ diff --git a/resources/sounds/connected.mp3 b/resources/sounds/connected.mp3 new file mode 100644 index 0000000..3e030fa Binary files /dev/null and b/resources/sounds/connected.mp3 differ diff --git a/resources/sounds/end_call.mp3 b/resources/sounds/end_call.mp3 new file mode 100644 index 0000000..c58e9ed Binary files /dev/null and b/resources/sounds/end_call.mp3 differ diff --git a/resources/sounds/micro_disable.mp3 b/resources/sounds/micro_disable.mp3 new file mode 100644 index 0000000..b87de43 Binary files /dev/null and b/resources/sounds/micro_disable.mp3 differ diff --git a/resources/sounds/micro_enable.mp3 b/resources/sounds/micro_enable.mp3 new file mode 100644 index 0000000..fd78422 Binary files /dev/null and b/resources/sounds/micro_enable.mp3 differ diff --git a/resources/sounds/ringtone.mp3 b/resources/sounds/ringtone.mp3 new file mode 100644 index 0000000..6c576c2 Binary files /dev/null and b/resources/sounds/ringtone.mp3 differ diff --git a/resources/sounds/sound_disable.mp3 b/resources/sounds/sound_disable.mp3 new file mode 100644 index 0000000..8168123 Binary files /dev/null and b/resources/sounds/sound_disable.mp3 differ diff --git a/resources/sounds/sound_enable.mp3 b/resources/sounds/sound_enable.mp3 new file mode 100644 index 0000000..797c56e Binary files /dev/null and b/resources/sounds/sound_enable.mp3 differ