Compare commits

..

27 Commits

Author SHA1 Message Date
7d5980a453 Подгонка regexp к серверной валидации 2026-04-11 21:38:24 +02:00
5193ceb071 Валидация вводимого имени пользователя 2026-04-11 21:35:34 +02:00
7434e16252 Исправление высоты текста у ссылки 2026-04-11 21:05:48 +02:00
c762e527c2 Исправление нестабильности HTTP соединения 2026-04-11 21:03:32 +02:00
3291def79b Исправление устаревшего имени в UserButton 2026-04-11 11:57:12 +02:00
032f92f8a2 Исправление индикатора непрочитанных в диалогах группы при синхронизаци 2026-04-11 11:51:21 +02:00
RoyceDa
f4592d03b0 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m41s
2026-04-10 18:56:13 +02:00
RoyceDa
6554483939 С binary на hex 2026-04-10 18:52:58 +02:00
RoyceDa
ba12db3c72 OPUS сборка 2026-04-10 17:54:48 +02:00
RoyceDa
b596d36543 Базовая версия голосовых сообщений и аудиоплеер. Кодирование OPUS 2026-04-10 17:20:44 +02:00
RoyceDa
93ef692eb5 Подготовка голосовых сообщений 2026-04-09 16:53:57 +02:00
RoyceDa
8fdfe9b786 Исправдение failed to decompress data 2026-04-09 16:36:23 +02:00
RoyceDa
547ac89987 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 4m4s
2026-04-08 23:05:03 +02:00
RoyceDa
130ad9c35a Исправление парсинга emoji и составных emoji (skin tones) 2026-04-08 22:58:49 +02:00
RoyceDa
adfc6add6f Исправление битых вложений в группах (на декодинг) 2026-04-08 21:55:59 +02:00
RoyceDa
cf29cecfd6 Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 5m58s
2026-04-04 18:59:52 +02:00
RoyceDa
779c265851 Поднятие версии, outline
All checks were successful
SP Builds / build (push) Successful in 4m25s
2026-04-04 18:34:18 +02:00
RoyceDa
8ac952071d Фикс протокола 2026-04-04 18:23:24 +02:00
RoyceDa
e1f5cb7eb8 Перевод звонка в активную стадию 2026-04-04 18:18:05 +02:00
RoyceDa
30f2c90015 Фикс ассиметричного обмена 2026-04-04 18:07:24 +02:00
RoyceDa
a341aedd8d Фикс бесконечного обмена ключами 2026-04-04 18:04:11 +02:00
RoyceDa
a9164c7087 Фикс обмена ключами 2026-04-04 18:01:52 +02:00
RoyceDa
04dd23dd5c Фикс обмена ключами 2026-04-04 18:01:09 +02:00
RoyceDa
5979c31120 Таймаут вызова 2026-04-04 17:46:38 +02:00
RoyceDa
c8c85991c7 Исправление протокола для правильного END_CALL 2026-04-04 17:19:43 +02:00
RoyceDa
c052fdae41 Реализация нового протокола звонков 2026-04-04 16:48:26 +02:00
RoyceDa
3492a881cc Поднятие версии
All checks were successful
SP Builds / build (push) Successful in 3m36s
2026-04-02 18:31:46 +02:00
26 changed files with 1513 additions and 232 deletions

View File

@@ -22,6 +22,7 @@ import { DialogStateProvider } from './providers/DialogStateProvider.tsx/DialogS
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
import { PlayerProvider } from './providers/PlayerProvider/PlayerProvider';
window.Buffer = Buffer;
export default function App() {
@@ -58,19 +59,21 @@ export default function App() {
<Topbar></Topbar>
<ContextMenuProvider>
<ImageViwerProvider>
<AvatarProvider>
<Routes>
<Route path="/" element={
getViewByLoginState()
} />
<Route path="/create-seed" element={<CreateSeed />} />
<Route path="/confirm-seed" element={<ConfirmSeed />} />
<Route path="/set-password" element={<SetPassword />} />
<Route path="/main/*" element={<Main />} />
<Route path="/exists-seed" element={<ExistsSeed />} />
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
</Routes>
</AvatarProvider>
<PlayerProvider>
<AvatarProvider>
<Routes>
<Route path="/" element={
getViewByLoginState()
} />
<Route path="/create-seed" element={<CreateSeed />} />
<Route path="/confirm-seed" element={<ConfirmSeed />} />
<Route path="/set-password" element={<SetPassword />} />
<Route path="/main/*" element={<Main />} />
<Route path="/exists-seed" element={<ExistsSeed />} />
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
</Routes>
</AvatarProvider>
</PlayerProvider>
</ImageViwerProvider>
</ContextMenuProvider>
</Box>

View File

@@ -1,7 +1,7 @@
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react";
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMicrophone, IconMoodSmile, IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
@@ -25,7 +25,8 @@ import { AnimatedButton } from "../AnimatedButton/AnimatedButton";
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
import { MentionList, Mention } from "../MentionList/MentionList";
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
import { useVoiceMessage } from "./useVoiceMessage";
import { VoiceRecorder } from "../VoiceRecorder/VoiceRecorder";
export function DialogInput() {
const colors = useRosettaColors();
@@ -47,6 +48,7 @@ export function DialogInput() {
const [mentionList, setMentionList] = useState<Mention[]>([]);
const mentionHandling = useRef<string>("");
const {getDraft, saveDraft} = useDrafts(dialog);
const {start, stop, isRecording, duration, waves, getAudioBlob, interpolateCompressWaves} = useVoiceMessage();
const avatars = useAvatars(
@@ -64,6 +66,15 @@ export function DialogInput() {
}]
], [], true);
const hasText = message.trim().length > 0;
const showSendIcon = hasText || attachments.length > 0 || isRecording;
const onMicroClick = () => {
if(!isRecording) {
start();
}
};
const fileDialog = useFileDialog({
multiple: false,
//naccept: '*',
@@ -188,8 +199,28 @@ export function DialogInput() {
mentionHandling.current = username;
}
const send = () => {
if(blocked || (message.trim() == "" && attachments.length <= 0)) {
const send = async () => {
if(blocked || (message.trim() == "" && attachments.length <= 0 && !isRecording)){
return;
}
if(isRecording){
const audioBlob = getAudioBlob();
stop();
if(!audioBlob){
return;
}
sendMessage("", [
{
blob: Buffer.from(await audioBlob.arrayBuffer()).toString('hex'),
id: generateRandomKey(8),
type: AttachmentType.VOICE,
preview: duration + "::" + interpolateCompressWaves(35).join(","),
transport: {
transport_server: "",
transport_tag: ""
}
}
]);
return;
}
sendMessage(message, attachments);
@@ -365,79 +396,118 @@ export function DialogInput() {
{!blocked &&
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
<Flex w={25} mt={10} justify={'center'}>
<Menu width={150} withArrow>
<Menu.Target>
<IconPaperclip stroke={1.5} style={{
cursor: 'pointer'
}} size={25} color={colors.chevrons.active}></IconPaperclip>
</Menu.Target>
<Menu.Dropdown style={{
userSelect: 'none'
}}>
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
<Menu.Item fz={'xs'} fw={500} leftSection={
<IconFile size={14}></IconFile>
} onClick={onClickPaperclip}>File</Menu.Item>
{((avatars.length > 0 && !hasGroup(dialog))
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
&& <Menu.Item fz={'xs'} fw={500} leftSection={
<IconCamera size={14}></IconCamera>
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
</Menu.Dropdown>
</Menu>
{isRecording && (
<IconTrash onClick={stop} style={{
cursor: 'pointer'
}} color={colors.error} stroke={1.5} size={25}></IconTrash>
)}
{!isRecording && (
<Menu width={150} withArrow>
<Menu.Target>
<IconPaperclip stroke={1.5} style={{
cursor: 'pointer'
}} size={25} color={colors.chevrons.active}></IconPaperclip>
</Menu.Target>
<Menu.Dropdown style={{
userSelect: 'none'
}}>
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
<Menu.Item fz={'xs'} fw={500} leftSection={
<IconFile size={14}></IconFile>
} onClick={onClickPaperclip}>File</Menu.Item>
{((avatars.length > 0 && !hasGroup(dialog))
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
&& <Menu.Item fz={'xs'} fw={500} leftSection={
<IconCamera size={14}></IconCamera>
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
</Menu.Dropdown>
</Menu>
)}
</Flex>
<Flex
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
align={'center'}
>
<RichTextInput
ref={editableDivRef}
style={{
border: 0,
minHeight: 45,
fontSize: 14,
background: 'transparent',
width: '100%',
paddingLeft: 10,
paddingRight: 10,
outline: 'none',
paddingTop: 10,
paddingBottom: 8
}}
placeholder="Type message..."
autoFocus
//ref={textareaRef}
//onPaste={onPaste}
//maxLength={2500}
//w={'100%'}
//h={'100%'}
onKeyDown={handleKeyDown}
onChange={setMessage}
onPaste={onPaste}
//dangerouslySetInnerHTML={{__html: message}}
></RichTextInput>
{!isRecording && <>
<RichTextInput
ref={editableDivRef}
style={{
border: 0,
minHeight: 45,
fontSize: 14,
background: 'transparent',
width: '100%',
paddingLeft: 10,
paddingRight: 10,
outline: 'none',
paddingTop: 10,
paddingBottom: 8
}}
placeholder="Type message..."
autoFocus
onKeyDown={handleKeyDown}
onChange={setMessage}
onPaste={onPaste}
></RichTextInput>
</>}
{isRecording && <>
<VoiceRecorder duration={duration} waves={waves}></VoiceRecorder>
</>}
</Flex>
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
<Popover withArrow>
<Popover.Target>
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
cursor: 'pointer'
}}></IconMoodSmile>
</Popover.Target>
<Popover.Dropdown p={0}>
<EmojiPicker
onEmojiClick={onEmojiClick}
searchDisabled
skinTonesDisabled
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
/>
</Popover.Dropdown>
</Popover>
<IconSend stroke={1.5} color={message.trim() == "" && attachments.length <= 0 ? colors.chevrons.active : colors.brandColor} onClick={send} style={{
cursor: 'pointer'
}} size={25}></IconSend>
{!isRecording && <>
<Popover withArrow>
<Popover.Target>
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
cursor: 'pointer'
}}></IconMoodSmile>
</Popover.Target>
<Popover.Dropdown p={0}>
<EmojiPicker
onEmojiClick={onEmojiClick}
searchDisabled
skinTonesDisabled
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
/>
</Popover.Dropdown>
</Popover>
</>}
<Box pos="relative" ml={isRecording ? 35 : 0} w={25} h={25}>
<Transition mounted={showSendIcon} transition="pop" duration={180} timingFunction="ease">
{(styles) => (
<IconSend
stroke={1.5}
color={colors.brandColor}
onClick={send}
style={{
...styles,
position: 'absolute',
inset: 0,
cursor: 'pointer'
}}
size={25}
/>
)}
</Transition>
<Transition mounted={!showSendIcon} transition="pop" duration={180} timingFunction="ease">
{(styles) => (
<IconMicrophone
stroke={1.5}
color={colors.chevrons.active}
onClick={onMicroClick}
style={{
...styles,
position: 'absolute',
inset: 0,
cursor: 'pointer'
}}
size={25}
/>
)}
</Transition>
</Box>
</Flex>
</Flex>}
{blocked && <Box mih={62} bg={colors.boxColor}>

View File

@@ -0,0 +1,273 @@
import { useState, useRef, useCallback, useEffect } from "react";
export function useVoiceMessage(): {
isRecording: boolean;
isPaused: boolean;
duration: number;
waves: number[];
start: () => Promise<void>;
stop: () => void;
pause: () => void;
play: () => void;
error: string | null;
getAudioBlob: () => Blob | null;
interpolateCompressWaves: (targetLength: number) => number[];
} {
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [duration, setDuration] = useState(0);
const [waves, setWaves] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const waveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const waveDataRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const stopWaveLoop = useCallback(() => {
if (waveTimerRef.current) {
clearInterval(waveTimerRef.current);
waveTimerRef.current = null;
}
}, []);
const startTimer = useCallback(() => {
if (timerRef.current) return;
timerRef.current = setInterval(() => {
setDuration((prev) => prev + 1);
}, 1000);
}, []);
const startWaveLoop = useCallback(() => {
stopWaveLoop();
const analyser = analyserRef.current;
if (!analyser) return;
if (!waveDataRef.current || waveDataRef.current.length !== analyser.frequencyBinCount) {
waveDataRef.current = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount));
}
const MAX_WAVES = 120;
const tick = () => {
if (!analyserRef.current || !waveDataRef.current) return;
analyserRef.current.getByteFrequencyData(waveDataRef.current);
let peak = 0;
for (let i = 0; i < waveDataRef.current.length; i++) {
const v = waveDataRef.current[i];
if (v > peak) peak = v;
}
const bar = peak / 255;
setWaves((prev) => {
const next = [...prev, bar];
return next.length > MAX_WAVES ? next.slice(next.length - MAX_WAVES) : next;
});
};
tick();
waveTimerRef.current = setInterval(tick, 300);
}, [stopWaveLoop]);
const cleanupAudio = useCallback(() => {
stopWaveLoop();
sourceRef.current?.disconnect();
sourceRef.current = null;
analyserRef.current = null;
waveDataRef.current = null;
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}, [stopWaveLoop]);
const start = useCallback(async () => {
try {
setError(null);
setDuration(0);
setWaves([]);
chunksRef.current = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0;
analyser.minDecibels = -100;
analyser.maxDecibels = -10;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
audioContextRef.current = audioContext;
analyserRef.current = analyser;
sourceRef.current = source;
// Выбираем лучший поддерживаемый кодек
const preferredTypes = [
"audio/webm;codecs=opus",
"audio/ogg;codecs=opus",
"audio/webm",
];
const mimeType = preferredTypes.find((t) => MediaRecorder.isTypeSupported(t)) ?? "";
const mediaRecorder = new MediaRecorder(stream, {
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 32_000,
});
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
cleanupAudio();
};
mediaRecorder.start(100);
setIsRecording(true);
setIsPaused(false);
startTimer();
startWaveLoop();
} catch (err) {
setError("Could not start voice recording. Please check microphone permissions.");
console.error("Voice recording error:", err);
}
}, [startTimer, startWaveLoop, cleanupAudio]);
const stop = useCallback(() => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
mediaRecorderRef.current = null;
setIsRecording(false);
setIsPaused(false);
clearTimer();
stopWaveLoop();
}
}, [isRecording, clearTimer, stopWaveLoop]);
const pause = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
mediaRecorderRef.current.pause();
setIsPaused(true);
clearTimer();
stopWaveLoop();
}
}, [clearTimer, stopWaveLoop]);
const play = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "paused") {
mediaRecorderRef.current.resume();
setIsPaused(false);
startTimer();
startWaveLoop();
}
}, [startTimer, startWaveLoop]);
useEffect(() => {
return () => {
clearTimer();
stopWaveLoop();
if (mediaRecorderRef.current && mediaRecorderRef.current?.state !== "inactive") {
mediaRecorderRef.current.stop();
}
cleanupAudio();
};
}, [clearTimer, stopWaveLoop, cleanupAudio]);
const getAudioBlob = useCallback((): Blob | null => {
if (chunksRef.current.length === 0) return null;
const mimeType = mediaRecorderRef.current?.mimeType ?? "audio/webm;codecs=opus";
return new Blob(chunksRef.current, { type: mimeType });
}, []);
const interpolateCompressWaves = useCallback((targetLength: number) => {
if (targetLength <= 0) return [];
if (waves.length === 0) return Array(targetLength).fill(0);
if (waves.length === targetLength) return waves;
if (waves.length > targetLength) {
const compressed: number[] = [];
const bucketSize = waves.length / targetLength;
for (let i = 0; i < targetLength; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
let max = 0;
for (let j = start; j < end && j < waves.length; j++) {
if (waves[j] > max) max = waves[j];
}
compressed.push(max);
}
return compressed;
}
if (targetLength === 1) return [waves[0]];
const stretched: number[] = [];
const lastSourceIndex = waves.length - 1;
for (let i = 0; i < targetLength; i++) {
const position = (i * lastSourceIndex) / (targetLength - 1);
const left = Math.floor(position);
const right = Math.min(Math.ceil(position), lastSourceIndex);
if (left === right) {
stretched.push(waves[left]);
continue;
}
const t = position - left;
const value = waves[left] * (1 - t) + waves[right] * t;
stretched.push(value);
}
return stretched;
}, [waves]);
return {
isRecording,
isPaused,
duration,
waves,
start,
stop,
pause,
play,
error,
getAudioBlob,
interpolateCompressWaves,
};
}

View File

@@ -17,6 +17,8 @@ import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu";
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
import { useMentions } from "@/app/providers/DialogStateProvider.tsx/useMentions";
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
export interface DialogProps extends DialogRow {
onClickDialog: (dialog: string) => void;
@@ -54,6 +56,7 @@ export function GroupDialog(props : DialogProps) {
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
const {openContextMenu} = useDialogContextMenu();
const {isMentioned} = useMentions();
const [protocolState] = useProtocolState();
useEffect(() => {
@@ -156,7 +159,7 @@ export function GroupDialog(props : DialogProps) {
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
)}
{unreaded > 0 && !lastMessageFromMe && !isMentioned(props.dialog_id) && <Badge
{unreaded > 0 && !lastMessageFromMe && protocolState != ProtocolState.SYNCHRONIZATION && !isMentioned(props.dialog_id) && <Badge
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
c={isInCurrentDialog ? colors.brandColor : 'white'}
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}

View File

@@ -9,6 +9,7 @@ import { AttachmentError } from "../AttachmentError/AttachmentError";
import { MessageAvatar } from "./MessageAvatar";
import { MessageProps } from "../Messages/Message";
import { MessageCall } from "./MessageCall";
import { MessageVoice } from "./MessageVoice";
export interface MessageAttachmentsProps {
attachments: Attachment[];
@@ -54,6 +55,8 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
case AttachmentType.CALL:
return <MessageCall {...attachProps} key={index}></MessageCall>
case AttachmentType.VOICE:
return <MessageVoice {...attachProps} key={index}></MessageVoice>
default:
return <AttachmentError key={index}></AttachmentError>;
}

View File

@@ -150,7 +150,7 @@ export function MessageImage(props: AttachmentProps) {
</Box>
</Flex>
</Portal>}
{(props.delivered == DeliveredMessageState.ERROR || (props.delivered != DeliveredMessageState.DELIVERED &&
{(props.delivered == DeliveredMessageState.ERROR || error || (props.delivered != DeliveredMessageState.DELIVERED &&
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
)) && (
<Overlay center h={'100%'} radius={8} color="#000" opacity={0.85}>

View File

@@ -0,0 +1,267 @@
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
import { AttachmentProps } from "./MessageAttachments";
import { Avatar, Box, Flex, Text, useMantineTheme } from "@mantine/core";
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
import { IconArrowDown, IconPlayerPauseFilled, IconPlayerPlayFilled, IconX } from "@tabler/icons-react";
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { useMemo, useRef } from "react";
import { usePlayerContext } from "@/app/providers/PlayerProvider/usePlayerContext";
const WAVE_BARS = 40;
const BAR_WIDTH = 2;
const BAR_GAP = 2;
const MIN_BAR_HEIGHT = 4;
const MAX_BAR_HEIGHT = 24;
function normalizeWaves(source: number[], targetLength: number): number[] {
if (targetLength <= 0) return [];
if (source.length === 0) return Array(targetLength).fill(0);
if (source.length === targetLength) return source;
if (source.length > targetLength) {
const compressed: number[] = [];
const bucketSize = source.length / targetLength;
for (let i = 0; i < targetLength; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
let max = 0;
for (let j = start; j < end && j < source.length; j++) {
if (source[j] > max) max = source[j];
}
compressed.push(max);
}
return compressed;
}
if (targetLength === 1) return [source[0]];
const stretched: number[] = [];
const lastSourceIndex = source.length - 1;
for (let i = 0; i < targetLength; i++) {
const position = (i * lastSourceIndex) / (targetLength - 1);
const left = Math.floor(position);
const right = Math.min(Math.ceil(position), lastSourceIndex);
if (left === right) {
stretched.push(source[left]);
continue;
}
const t = position - left;
stretched.push(source[left] * (1 - t) + source[right] * t);
}
return stretched;
}
function formatTime(seconds: number) {
const s = Math.max(0, Math.floor(seconds));
const m = Math.floor(s / 60).toString().padStart(2, "0");
const r = (s % 60).toString().padStart(2, "0");
return `${m}:${r}`;
}
export function MessageVoice(props: AttachmentProps) {
const { downloadPercentage, downloadStatus, uploadedPercentage, download, getPreview } = useAttachment(
props.attachment,
props.parent,
);
const theme = useMantineTheme();
const colors = useRosettaColors();
const preview = getPreview() || "";
const [durationPart = "0", wavesPart = ""] = preview.split("::");
const previewDuration = Number.parseInt(durationPart, 10) || 0;
const rawWaves = useMemo(
() =>
wavesPart
.split(",")
.map((s) => Number.parseFloat(s))
.filter((n) => Number.isFinite(n) && n >= 0),
[wavesPart]
);
const waves = useMemo(() => normalizeWaves(rawWaves, WAVE_BARS), [rawWaves]);
const peak = useMemo(() => {
const max = Math.max(...waves, 0);
return max > 0 ? max : 1;
}, [waves]);
const isUploading =
props.delivered === DeliveredMessageState.WAITING &&
uploadedPercentage > 0 &&
uploadedPercentage < 100;
const error = downloadStatus === DownloadStatus.ERROR;
const waveformWidth = WAVE_BARS * BAR_WIDTH + (WAVE_BARS - 1) * BAR_GAP;
const waveformRef = useRef<HTMLDivElement | null>(null);
const {
playAudio,
pause,
duration: currentDuration,
playing,
setDuration,
totalDuration,
currentMessageId,
} = usePlayerContext();
const messageId = String((props.parent as any)?.id ?? (props.attachment as any)?.messageId ?? props.attachment.id);
const isCurrentTrack = currentMessageId === messageId;
const fullDuration = Math.max(isCurrentTrack && totalDuration > 0 ? totalDuration : previewDuration, 1);
const safeCurrent = isCurrentTrack ? currentDuration : 0;
const playbackProgress = Math.max(0, Math.min(1, safeCurrent / fullDuration));
const createAudioBlob = () => new Blob([Buffer.from(props.attachment.blob, "hex")], { type: "audio/webm;codecs=opus" });
const ensureStarted = (seekToSec?: number) => {
const blob = createAudioBlob();
playAudio("Voice Message", "", blob, messageId);
if (typeof seekToSec === "number") {
requestAnimationFrame(() => setDuration(seekToSec));
}
};
const handleMainAction = () => {
if (error) return;
if (downloadStatus !== DownloadStatus.DOWNLOADED) {
download();
return;
}
if (!isCurrentTrack) {
ensureStarted();
return;
}
if (playing) {
pause();
return;
}
ensureStarted(Math.max(0, safeCurrent));
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (error || downloadStatus !== DownloadStatus.DOWNLOADED) return;
const rect = waveformRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
const x = e.clientX - rect.left;
const progress = Math.max(0, Math.min(1, x / rect.width));
const seekTo = progress * fullDuration;
if (!isCurrentTrack) {
ensureStarted(seekTo);
return;
}
setDuration(seekTo);
};
const timeText =
isCurrentTrack && safeCurrent > 0
? `-${formatTime(Math.max(0, fullDuration - safeCurrent))}`
: formatTime(fullDuration);
return (
<Flex gap="sm" align="center">
<Avatar
bg={error ? colors.error : colors.brandColor}
size={40}
style={{ cursor: "pointer", position: "relative" }}
onClick={handleMainAction}
>
{!error && (
<>
{downloadStatus === DownloadStatus.DOWNLOADING && (
<div style={{ position: "absolute", top: 0, left: 0 }}>
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} />
</div>
)}
{isUploading && (
<div style={{ position: "absolute", top: 0, left: 0 }}>
<AnimatedRoundedProgress color="#fff" size={40} value={uploadedPercentage} />
</div>
)}
{downloadStatus !== DownloadStatus.DOWNLOADED && <IconArrowDown color="white" size={22} />}
{downloadStatus === DownloadStatus.DOWNLOADED && !isUploading &&
(isCurrentTrack && playing ? (
<IconPlayerPauseFilled color="white" size={22} />
) : (
<IconPlayerPlayFilled color="white" size={22} />
))}
</>
)}
{(error || isUploading) && <IconX color="white" size={22} />}
</Avatar>
<Flex direction="column">
<Box
ref={waveformRef}
w={waveformWidth}
h={32}
onClick={handleSeek}
style={{ overflow: "hidden", cursor: "pointer" }}
>
<Flex h="100%" align="center" gap={BAR_GAP} wrap="nowrap">
{waves.map((value, index) => {
const normalized = Math.max(0, Math.min(1, value / peak));
const height = Math.max(
MIN_BAR_HEIGHT,
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
);
const passed = playbackProgress * waves.length - index;
const fillPercent = Math.max(0, Math.min(1, passed));
const inactiveColor = theme.colors.gray[4];
const activeColor = colors.brandColor;
let background = inactiveColor;
if (fillPercent >= 1) {
background = activeColor;
} else if (fillPercent > 0) {
background = `linear-gradient(90deg, ${activeColor} 0%, ${activeColor} ${fillPercent * 100}%, ${inactiveColor} ${fillPercent * 100}%, ${inactiveColor} 100%)`;
}
return (
<Box
key={index}
w={BAR_WIDTH}
h={height}
style={{
flex: `0 0 ${BAR_WIDTH}px`,
borderRadius: 999,
background,
}}
/>
);
})}
</Flex>
</Box>
<Text size="xs" c="dimmed">
{timeText}
</Text>
</Flex>
</Flex>
);
}

View File

@@ -24,6 +24,8 @@ export interface SettingsInputDefaultProps {
mt?: StyleProp<MantineSpacing>;
rightSection?: ReactNode;
type?: HTMLInputTypeAttribute;
onErrorStateChange?: (error: boolean) => void;
regexp?: RegExp;
}
export interface SettingsInputGroupProps {
mt?: StyleProp<MantineSpacing>;
@@ -260,7 +262,6 @@ function SettingsInputClickable(
function SettingsInputDefault(props : SettingsInputDefaultProps) {
const colors = useRosettaColors();
const input = useRef<any>(undefined);
const onClick = (e : MouseEvent) => {
e.stopPropagation();
if(!props.disabled){
@@ -268,6 +269,19 @@ function SettingsInputDefault(props : SettingsInputDefaultProps) {
return;
}
}
const onChange = (e) => {
let value = e.target.value;
if(props.regexp && !props.regexp.test(value)) {
props.onErrorStateChange && props.onErrorStateChange(true);
props.onChange && props.onChange(e);
}else{
props.onErrorStateChange && props.onErrorStateChange(false);
console.info('fa');
props.onChange && props.onChange(e);
}
}
return (<>
<Paper mt={props.mt} style={props.style} withBorder styles={{
root: {
@@ -298,7 +312,7 @@ function SettingsInputDefault(props : SettingsInputDefaultProps) {
{!props.rightSection && (
<Input type={props.type} defaultValue={!props.onChange ? props.value : undefined} value={!props.onChange ? undefined : props.value} ref={input} disabled={props.disabled} onClick={(e) => {
onClick(e)
}} onChange={props.onChange} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
}} onChange={onChange} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
input: classes.input
}} placeholder={props.placeholder}></Input>)
}

View File

@@ -42,6 +42,20 @@ export function TextParser(props: TextParserProps) {
const theme = useMantineTheme();
let entityCount = 0;
const UNICODE_EMOJI_SEQUENCE_REGEX =
/(?:\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
const UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL = new RegExp(
UNICODE_EMOJI_SEQUENCE_REGEX.source,
"gu"
);
const toUnified = (value: string): string =>
Array.from(value)
.map((ch) => ch.codePointAt(0)?.toString(16))
.filter(Boolean)
.join("-");
const formatRules : FormatRule[] = [
{
pattern: [
@@ -58,7 +72,7 @@ export function TextParser(props: TextParserProps) {
return <Anchor style={{
userSelect: 'auto',
color: props.__reserved_2 ? theme.colors.blue[2] : undefined
}} fz={14} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
}} fz={13} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
},
flush: (match: string) => {
return <>{match}</>;
@@ -120,18 +134,21 @@ export function TextParser(props: TextParserProps) {
}
},
{
// unicode emojis
pattern: [/\p{Emoji_Presentation}/u],
// unicode emojis (including composite sequences)
pattern: [UNICODE_EMOJI_SEQUENCE_REGEX],
render: (match: string) => {
let textWithoutEmojis = props.text.replace(/\p{Emoji_Presentation}/gu, '');
if(textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
return <Emoji size={40} unified={match.codePointAt(0)?.toString(16) || ''}></Emoji>;
const textWithoutEmojis = props.text.replace(UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL, "");
const unified = toUnified(match);
if (textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
return <Emoji size={40} unified={unified}></Emoji>;
}
return <Emoji unified={match.codePointAt(0)?.toString(16) || ''}></Emoji>;
return <Emoji unified={unified}></Emoji>;
},
flush: (match: string) => {
return <Emoji unified={match.codePointAt(0)?.toString(16) || ''}></Emoji>;
}
return <Emoji unified={toUnified(match)}></Emoji>;
}
},
{
// :emoji_code:

View File

@@ -5,15 +5,23 @@ import { useNavigate } from 'react-router-dom';
import { useUserInformation } from '@/app/providers/InformationProvider/useUserInformation';
import { usePublicKey } from '@/app/providers/AccountProvider/usePublicKey';
import { useAvatars } from '@/app/providers/AvatarProvider/useAvatars';
import { useEffect } from 'react';
export function UserButton() {
const navigate = useNavigate();
const publicKey = usePublicKey();
const [userInfo] = useUserInformation(publicKey);
const [userInfo, _, forceUpdateUserInformation] = useUserInformation(publicKey);
const avatars = useAvatars(publicKey);
const loading = userInfo.publicKey !== publicKey;
useEffect(() => {
/**
* Обновляем информацию о пользователе принудительно при рендере левого меню
*/
forceUpdateUserInformation();
}, []);
return (
<UnstyledButton p={'sm'} className={classes.user} onClick={() => navigate("/main/profile/me")}>
<Group>

View File

@@ -0,0 +1,278 @@
import { Box, Flex, Text, useMantineTheme } from "@mantine/core";
import { useEffect, useRef, useState } from "react";
interface VoiceRecorderProps {
duration: number;
waves: number[];
}
type AnimatedBar = {
id: number;
value: number;
entered: boolean;
};
const VISIBLE_BARS = 50;
const BAR_WIDTH = 3;
const BAR_GAP = 2;
const STEP_PX = BAR_WIDTH + BAR_GAP;
const COMPONENT_HEIGHT = 45;
const MAX_BAR_HEIGHT = 28;
const MIN_BAR_HEIGHT = 4;
export function VoiceRecorder(props: VoiceRecorderProps) {
const theme = useMantineTheme();
const [bars, setBars] = useState<AnimatedBar[]>([]);
const [subShift, setSubShift] = useState(0);
const prevLengthRef = useRef(0);
const prevWavesRef = useRef<number[]>([]);
const idRef = useRef(0);
const enterFrameRef = useRef<number | null>(null);
const scrollFrameRef = useRef<number | null>(null);
const lastAppendAtRef = useRef<number | null>(null);
const appendIntervalRef = useRef(120);
const barsLengthRef = useRef(0);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const secs = (seconds % 60).toString().padStart(2, "0");
return `${mins}:${secs}`;
};
useEffect(() => {
barsLengthRef.current = bars.length;
}, [bars.length]);
useEffect(() => {
if (props.waves.length === 0) {
setBars([]);
setSubShift(0);
prevLengthRef.current = 0;
prevWavesRef.current = [];
lastAppendAtRef.current = null;
return;
}
if (props.waves.length < prevLengthRef.current) {
const resetBars = props.waves.slice(-VISIBLE_BARS).map((value) => ({
id: idRef.current++,
value,
entered: true,
}));
setBars(resetBars);
setSubShift(0);
prevLengthRef.current = props.waves.length;
prevWavesRef.current = props.waves;
lastAppendAtRef.current = performance.now();
return;
}
const prevWaves = prevWavesRef.current;
let appended: number[] = [];
// Обычный режим: длина выросла
if (props.waves.length > prevLengthRef.current) {
appended = props.waves.slice(prevLengthRef.current);
} else if (props.waves.length === prevLengthRef.current && props.waves.length > 0) {
// Rolling buffer: длина та же, но данные сдвигаются
let changed = false;
if (prevWaves.length !== props.waves.length) {
changed = true;
} else {
for (let i = 0; i < props.waves.length; i++) {
if (props.waves[i] !== prevWaves[i]) {
changed = true;
break;
}
}
}
if (changed) {
appended = [props.waves[props.waves.length - 1]];
}
}
if (appended.length > 0) {
const now = performance.now();
if (lastAppendAtRef.current != null) {
const dt = now - lastAppendAtRef.current;
const perBar = dt / appended.length;
appendIntervalRef.current = appendIntervalRef.current * 0.7 + perBar * 0.3;
}
lastAppendAtRef.current = now;
setSubShift(0);
const newIds: number[] = [];
setBars((prev) => {
const next = [...prev];
appended.forEach((value) => {
const id = idRef.current++;
newIds.push(id);
next.push({
id,
value,
entered: false,
});
});
return next.slice(-VISIBLE_BARS);
});
if (enterFrameRef.current) {
cancelAnimationFrame(enterFrameRef.current);
}
enterFrameRef.current = requestAnimationFrame(() => {
setBars((prev) => {
const ids = new Set(newIds);
return prev.map((bar) => (ids.has(bar.id) ? { ...bar, entered: true } : bar));
});
});
}
prevLengthRef.current = props.waves.length;
prevWavesRef.current = props.waves;
}, [props.waves]);
useEffect(() => {
const tick = () => {
const startedAt = lastAppendAtRef.current;
if (startedAt != null) {
const elapsed = performance.now() - startedAt;
const interval = Math.max(16, appendIntervalRef.current);
const progress = Math.min(1, elapsed / interval);
const smoothShift = barsLengthRef.current >= VISIBLE_BARS ? -progress * STEP_PX : 0;
setSubShift(smoothShift);
} else {
setSubShift(0);
}
scrollFrameRef.current = requestAnimationFrame(tick);
};
scrollFrameRef.current = requestAnimationFrame(tick);
return () => {
if (scrollFrameRef.current) {
cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
useEffect(() => {
return () => {
if (enterFrameRef.current) {
cancelAnimationFrame(enterFrameRef.current);
}
if (scrollFrameRef.current) {
cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const waveformWidth = VISIBLE_BARS * BAR_WIDTH + (VISIBLE_BARS - 1) * BAR_GAP;
return (
<Flex
direction="row"
h={COMPONENT_HEIGHT}
mih={COMPONENT_HEIGHT}
mah={COMPONENT_HEIGHT}
align="center"
justify="center"
gap="xs"
px={6}
>
<Text size="xs" c="dimmed" w={36}>
{formatDuration(props.duration)}
</Text>
<Box
w={waveformWidth}
h={COMPONENT_HEIGHT}
style={{
overflow: "hidden",
}}
>
<Flex
h="100%"
align="center"
gap={BAR_GAP}
wrap="nowrap"
style={{ transform: `translateX(${subShift}px)` }}
>
{Array.from({ length: VISIBLE_BARS }).map((_, index) => {
const bar = bars[index];
if (!bar) {
return (
<Box
key={`empty-${index}`}
w={BAR_WIDTH}
h={MIN_BAR_HEIGHT}
style={{
flex: `0 0 ${BAR_WIDTH}px`,
borderRadius: 999,
background: theme.colors.gray[3],
opacity: 0.22,
}}
/>
);
}
const normalized = Math.max(0, Math.min(1, bar.value));
const height = Math.max(
MIN_BAR_HEIGHT,
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
);
const isLast = index === bars.length - 1;
const isNearTail = index >= bars.length - 3;
return (
<Box
key={bar.id}
w={BAR_WIDTH}
h={height}
style={{
flex: `0 0 ${BAR_WIDTH}px`,
alignSelf: "center",
borderRadius: 999,
background: isLast
? `linear-gradient(180deg, ${theme.colors.blue[3]} 0%, ${theme.colors.blue[5]} 100%)`
: `linear-gradient(180deg, ${theme.colors.blue[4]} 0%, ${theme.colors.blue[6]} 100%)`,
boxShadow: isLast
? `0 0 10px ${theme.colors.blue[4]}55`
: isNearTail
? `0 0 6px ${theme.colors.blue[4]}22`
: "none",
transform: bar.entered ? "scaleY(1)" : "scaleY(0.18)",
transformOrigin: "center center",
transition: [
"height 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
"transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
"opacity 220ms ease",
"box-shadow 220ms ease",
].join(", "),
willChange: "height, transform, opacity",
}}
/>
);
})}
</Flex>
</Box>
</Flex>
);
}

View File

@@ -36,7 +36,7 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
const publicKey = usePublicKey();
const privatePlain = usePrivatePlain();
const {updateAttachmentInDialogCache} = useDialogsCache();
const {info} = useConsoleLogger('useAttachment');
const { info, error } = useConsoleLogger('useAttachment');
const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
const {getDownloadsPath} = useCore();
const {hasGroup} = useGroups();
@@ -137,8 +137,7 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
downloadedBlob = await downloadFile(attachment.id,
downloadTag, attachment.transport.transport_server);
} catch (e) {
console.info(e);
info("Error downloading attachment: " + attachment.id);
error("Error downloading attachment: " + attachment.id);
setDownloadStatus(DownloadStatus.ERROR);
return;
}

View File

@@ -17,7 +17,7 @@ export function usePrepareAttachment() {
const {uploadFile} = useTransport();
const {updateDialog} = useDialogsList();
const {runQuery, getQuery} = useDatabase();
const {info} = useConsoleLogger('usePrepareAttachment');
const {info, error} = useConsoleLogger('usePrepareAttachment');
const {getDialogCache} = useDialogsCache();
const context = useContext(DialogContext);
const transportServer = useTransportServer();
@@ -197,11 +197,19 @@ export function usePrepareAttachment() {
const upid = attachment.id;
info(`Uploading attachment with upid: ${upid}`);
info(`Attachment content length: ${content.length}`);
let tag = await uploadFile(upid, content);
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
let tag = await uploadFile(upid, content).catch(() => {
error(`Network error while uploading attachment ${upid}`);
});
if(!tag){
/**
* При ошибке загрузки по сети
*/
stopUpdateTimeInUpAttachment();
console.info("stop upd")
continue;
}
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
stopUpdateTimeInUpAttachment();
const preparedAttachment : Attachment = {
...attachment,
transport: {
@@ -220,6 +228,12 @@ export function usePrepareAttachment() {
}
}
const stopUpdateTimeInUpAttachment = () => {
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
}
}
return {
prepareAttachmentsToSend
}

View File

@@ -16,7 +16,6 @@ import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE";
import { useDeattachedSender } from "../DialogProvider/useDeattachedSender";
import { AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
import { generateRandomKey } from "@/app/utils/utils";
import { useSystemInformation } from "../SystemProvider/useSystemInformation";
export interface CallContextValue {
call: (callable: string) => void;
@@ -86,7 +85,12 @@ export function CallProvider(props : CallProviderProps) {
const soundRef = useRef<boolean>(true);
const {sendMessage} = useDeattachedSender();
const hasRemoteTrackRef = useRef<boolean>(false);
const systemInformation = useSystemInformation();
/**
* Используются для входа в звонок
*/
const callSessionIdRef = useRef<string>("");
const callTokenRef = useRef<string>("");
const {playSound, stopSound, stopLoopSound} = useSound();
const {setWindowPriority} = useWindow();
@@ -196,8 +200,6 @@ export function CallProvider(props : CallProviderProps) {
let answerSignal = new PacketWebRTC();
answerSignal.setSignalType(WebRTCSignalType.ANSWER);
answerSignal.setSdpOrCandidate(JSON.stringify(answer));
answerSignal.setPublicKey(publicKey);
answerSignal.setDeviceId(systemInformation.id);
send(answerSignal);
info("Received WebRTC offer, set remote description and sent answer");
return;
@@ -214,15 +216,13 @@ export function CallProvider(props : CallProviderProps) {
openCallsModal("The connection with the user was lost. The call has ended.")
end();
}
if(activeCall){
if(signalType == SignalType.RINGING_TIMEOUT) {
/**
* У нас уже есть активный звонок, игнорируем все сигналы, кроме сигналов от текущего звонка
* Другой стороне был отправлен сигнал звонка, но она не ответила на него в течении определенного времени
*/
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;
}
openCallsModal("The user did not answer the call in time. Please try again later.");
end();
return;
}
if(signalType == SignalType.END_CALL){
/**
@@ -239,76 +239,67 @@ export function CallProvider(props : CallProviderProps) {
/**
* У нас уже есть активный звонок, отправляем сигнал другой стороне, что линия занята
*/
let busySignal = new PacketSignalPeer();
busySignal.setSrc(publicKey);
busySignal.setDst(packet.getSrc());
busySignal.setSignalType(SignalType.END_CALL_BECAUSE_BUSY);
send(busySignal);
info("Received incoming call from " + packet.getSrc() + " but we are already on a call, sent busy signal");
return;
}
callSessionIdRef.current = packet.getCallId();
callTokenRef.current = packet.getJoinToken();
setWindowPriority(true);
playSound("ringtone.mp3", true);
setActiveCall(packet.getSrc());
setCallState(CallState.INCOMING);
setShowCallView(true);
}
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLER){
console.info("EXCHANGE SIGNAL RECEIVED, CALLER ROLE");
/**
* Другая сторона сгенерировала ключи для сессии и отправила нам публичную часть,
* теперь мы можем создать общую секретную сессию для шифрования звонка
*/
const sharedPublic = packet.getSharedPublic();
if(!sharedPublic){
info("Received key exchange signal without shared public key");
return;
}
const sessionKeys = generateSessionKeys();
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
info("Generated shared secret for call session: " + sharedSecretRef.current);
/**
* Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию
*/
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(packet.getSrc());
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
send(signalPacket);
setCallState(CallState.WEB_RTC_EXCHANGE);
/**
* Создаем комнату на сервере SFU, комнату создает звонящий
*/
let webRtcSignal = new PacketSignalPeer();
webRtcSignal.setSignalType(SignalType.CREATE_ROOM);
webRtcSignal.setSrc(publicKey);
webRtcSignal.setDst(packet.getSrc());
send(webRtcSignal);
}
if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLEE){
if(signalType == SignalType.KEY_EXCHANGE){
console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE");
/**
* Мы отправили свою публичную часть ключа другой стороне,
* теперь мы получили ее публичную часть и можем создать общую
* секретную сессию для шифрования звонка
* Другая сторона отправила нам ключи, теперь отправляем ей свои для генерации общего секрета
*/
const sharedPublic = packet.getSharedPublic();
if(!sharedPublic){
info("Received key exchange signal without shared public key");
return;
}
}
if(!sessionKeys){
info("Received key exchange signal but session keys are not generated");
return;
}
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
info("Generated shared secret for call session: " + sharedSecretRef.current);
setCallState(CallState.WEB_RTC_EXCHANGE);
if(roleRef.current == CallRole.CALLER){
/**
* Вызывающий уже отправил ключ, сессия сгенерирована, сообщаем серверу что звонок активен
*/
const activeSignal = new PacketSignalPeer();
activeSignal.setSrc(publicKey);
activeSignal.setDst(activeCall);
activeSignal.setSignalType(SignalType.ACTIVE);
send(activeSignal);
return;
}
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(activeCall);
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex'));
send(signalPacket);
}
if(signalType == SignalType.CREATE_ROOM) {
if(signalType == SignalType.ACCEPT){
/**
* Другая сторона приняла наш звонок, комната на SFU создалась, нужно сгенерировать ключи
*/
const keys = generateSessionKeys();
const signalPacket = new PacketSignalPeer();
signalPacket.setSrc(publicKey);
signalPacket.setDst(activeCall);
signalPacket.setSignalType(SignalType.KEY_EXCHANGE);
signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex'));
send(signalPacket);
}
if(signalType == SignalType.ACTIVE) {
if(!sessionKeys){
/**
* Сервер может отправить CREATE_ROOM сигнал, даже если мы приняли звонок на другом устройстве, по этому проверяем,
@@ -319,12 +310,6 @@ export function CallProvider(props : CallProviderProps) {
end();
return;
}
/**
* Создана комната для обмена WebRTC потоками, но такое событие сервер может отправить даже если звонок
* был принят с другого устройства, по этому проверяем, наш ли звонок
*/
roomIdRef.current = packet.getRoomId();
info("WebRTC room created with id: " + packet.getRoomId());
/**
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
*/
@@ -341,8 +326,6 @@ export function CallProvider(props : CallProviderProps) {
let candidateSignal = new PacketWebRTC();
candidateSignal.setSignalType(WebRTCSignalType.ICE_CANDIDATE);
candidateSignal.setSdpOrCandidate(JSON.stringify(event.candidate));
candidateSignal.setPublicKey(publicKey);
candidateSignal.setDeviceId(systemInformation.id);
send(candidateSignal);
}
}
@@ -404,8 +387,6 @@ export function CallProvider(props : CallProviderProps) {
let offerSignal = new PacketWebRTC();
offerSignal.setSignalType(WebRTCSignalType.OFFER);
offerSignal.setSdpOrCandidate(JSON.stringify(offer));
offerSignal.setPublicKey(publicKey);
offerSignal.setDeviceId(systemInformation.id);
send(offerSignal);
return;
}
@@ -429,7 +410,9 @@ export function CallProvider(props : CallProviderProps) {
{text}
</Text>
<Flex align={'center'} justify={'flex-end'}>
<Button color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
<Button style={{
outline: 'none'
}} color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
Close
</Button>
</Flex>
@@ -471,6 +454,8 @@ export function CallProvider(props : CallProviderProps) {
const packetSignal = new PacketSignalPeer();
packetSignal.setSrc(publicKey);
packetSignal.setDst(activeCall);
packetSignal.setCallId(callSessionIdRef.current);
packetSignal.setJoinToken(callTokenRef.current);
packetSignal.setSignalType(SignalType.END_CALL);
send(packetSignal);
end();
@@ -541,15 +526,22 @@ export function CallProvider(props : CallProviderProps) {
stopLoopSound();
stopSound();
/**
* Звонок принят, генерируем ключи для сессии и отправляем их другой стороне для установления защищенного канала связи
* Звонок принят, генерируем свой ключ для будующего обмена
*/
generateSessionKeys();
/**
* Отправляем сигнал что звонок принят другой стороне, чтобы она могла начать обмен ключами и установку соединения
*/
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'));
signalPacket.setCallId(callSessionIdRef.current);
signalPacket.setJoinToken(callTokenRef.current);
signalPacket.setSignalType(SignalType.ACCEPT);
send(signalPacket);
/**
* Устанавливаем состояние звонка и стадию обмена ключами
*/
setCallState(CallState.KEY_EXCHANGE);
roleRef.current = CallRole.CALLEE;
}

View File

@@ -238,6 +238,10 @@ export function DialogProvider(props: DialogProviderProps) {
* Если это групповое сообщение, то получаем ключ группы
*/
decryptKey = await getGroupKey(props.dialog);
/**
* Приводим к HEX так как этого требует формат расшифровки вложений в приложении
*/
decryptKey = Buffer.from(decryptKey).toString('hex');
}
if(!message.from_me && !hasGroup(props.dialog)){
/**
@@ -569,7 +573,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content,
timestamp: timestamp,
readed: 0,
chacha_key: groupKey,
chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: 1,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,
@@ -723,7 +727,7 @@ export function DialogProvider(props: DialogProviderProps) {
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: groupKey,
chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,

View File

@@ -146,8 +146,12 @@ export function useDialog() : {
}
console.info("Sending key for message ", key.toString('hex'));
console.info(attachemnts);
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){
if(preparedToNetworkSendAttachements.length < attachemnts.length){
/**
* Если не удалось нормально загрузить все вложения - тогда не отправляем сообщение
*/
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog);
return;

View File

@@ -140,7 +140,7 @@ export function useDialogFiber() {
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: groupKey,
chacha_key: Buffer.from(groupKey).toString('hex'),
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,

View File

@@ -0,0 +1,300 @@
import { createContext, useEffect, useRef, useState } from "react";
export interface PlayerContextValue {
playAudio: (
artist: string,
title: string,
audio: string | Blob | File,
messageId?: string | null
) => void;
playing: boolean;
pause: () => void;
resume: () => void;
stop: () => void;
setDuration: (duration: number) => void;
duration: number;
totalDuration: number;
currentMessageId: string | null;
lastMessageId: string | null;
lastError: string | null;
}
export const PlayerContext = createContext<PlayerContextValue | null>(null);
interface PlayerProviderProps {
children: React.ReactNode;
}
export function PlayerProvider(props: PlayerProviderProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const objectUrlRef = useRef<string | null>(null);
const rafTimeUpdateRef = useRef<number | null>(null);
const isLoadingRef = useRef(false);
const isSeekingRef = useRef(false);
const durationRef = useRef(0);
const totalDurationRef = useRef(0);
const isPlayingRef = useRef(false);
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDurationState] = useState(0);
const [totalDuration, setTotalDuration] = useState(0);
const [lastError, setLastError] = useState<string | null>(null);
const [currentMessageId, setCurrentMessageId] = useState<string | null>(null);
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
const commitPlaying = (next: boolean) => {
if (isPlayingRef.current === next) return;
isPlayingRef.current = next;
setIsPlaying(next);
};
const commitDuration = (next: number) => {
const safe = Number.isFinite(next) && next >= 0 ? next : 0;
if (Math.abs(safe - durationRef.current) < 0.033) return;
durationRef.current = safe;
setDurationState(safe);
};
const commitTotalDuration = (next: number) => {
const safe = Number.isFinite(next) && next > 0 ? next : 0;
if (Math.abs(safe - totalDurationRef.current) < 0.05) return;
totalDurationRef.current = safe;
setTotalDuration(safe);
};
const decodeMediaError = (err: MediaError | null) => {
if (!err) return "Unknown media error";
switch (err.code) {
case MediaError.MEDIA_ERR_ABORTED:
return "Playback aborted";
case MediaError.MEDIA_ERR_NETWORK:
return "Network error while loading audio";
case MediaError.MEDIA_ERR_DECODE:
return "Audio decode error";
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
return "Audio source is not supported";
default:
return `Unknown media error (${err.code})`;
}
};
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onPlay = () => {
if (isLoadingRef.current) return;
commitPlaying(true);
};
const onPause = () => {
if (isLoadingRef.current) return;
commitPlaying(false);
};
const onEnded = () => {
commitPlaying(false);
durationRef.current = 0;
setDurationState(0);
setCurrentMessageId(null);
};
const onTimeUpdate = () => {
if (isLoadingRef.current) return;
if (isSeekingRef.current) return;
if (rafTimeUpdateRef.current != null) return;
rafTimeUpdateRef.current = requestAnimationFrame(() => {
rafTimeUpdateRef.current = null;
if (!isLoadingRef.current && !isSeekingRef.current) {
commitDuration(audio.currentTime || 0);
}
});
};
const onLoadedMetadata = () => commitTotalDuration(audio.duration);
const onDurationChange = () => commitTotalDuration(audio.duration);
const onSeeked = () => {
if (isSeekingRef.current) {
isSeekingRef.current = false;
if (!isLoadingRef.current) commitDuration(audio.currentTime || 0);
return;
}
if (isLoadingRef.current) return;
commitDuration(audio.currentTime || 0);
};
const onCanPlay = () => {
if (isLoadingRef.current) isLoadingRef.current = false;
};
const onError = (_e: Event) => {
const message = decodeMediaError(audio.error);
setLastError(message);
console.error("Audio playback error", {
message,
mediaError: audio.error,
currentSrc: audio.currentSrc,
readyState: audio.readyState,
networkState: audio.networkState,
});
};
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
audio.addEventListener("ended", onEnded);
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("loadedmetadata", onLoadedMetadata);
audio.addEventListener("durationchange", onDurationChange);
audio.addEventListener("seeked", onSeeked);
audio.addEventListener("canplay", onCanPlay);
audio.addEventListener("error", onError);
return () => {
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
audio.removeEventListener("ended", onEnded);
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
audio.removeEventListener("durationchange", onDurationChange);
audio.removeEventListener("seeked", onSeeked);
audio.removeEventListener("canplay", onCanPlay);
audio.removeEventListener("error", onError);
if (rafTimeUpdateRef.current != null) {
cancelAnimationFrame(rafTimeUpdateRef.current);
rafTimeUpdateRef.current = null;
}
};
}, []);
useEffect(() => {
return () => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
};
}, []);
const playAudio = (
artist: string,
title: string,
audio: string | Blob | File,
messageId?: string | null
) => {
const el = audioRef.current;
if (!el) return;
// чтобы не было warning о неиспользуемых args при строгих правилах
void artist;
void title;
setLastError(null);
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
const audioSrc = typeof audio === "string" ? audio : URL.createObjectURL(audio);
if (typeof audio !== "string") {
objectUrlRef.current = audioSrc;
}
isLoadingRef.current = true;
isSeekingRef.current = false;
el.src = audioSrc;
durationRef.current = 0;
const msgId = messageId ?? null;
setCurrentMessageId(msgId);
if (msgId) setLastMessageId(msgId);
isPlayingRef.current = true;
setIsPlaying(true);
const prevDuration = durationRef.current;
requestAnimationFrame(() => {
if (durationRef.current === prevDuration) {
setDurationState(0);
}
});
void el.play().catch((err) => {
isLoadingRef.current = false;
commitPlaying(false);
setLastError(err instanceof Error ? err.message : "play() failed");
});
};
const pause = () => {
const el = audioRef.current;
if (!el) return;
el.pause();
};
const resume = () => {
const el = audioRef.current;
if (!el) return;
commitPlaying(true);
void el.play().catch((err) => {
commitPlaying(false);
setLastError(err instanceof Error ? err.message : "resume() failed");
});
};
const stop = () => {
const el = audioRef.current;
if (!el) return;
isLoadingRef.current = true;
el.pause();
el.currentTime = 0;
isLoadingRef.current = false;
durationRef.current = 0;
setDurationState(0);
commitPlaying(false);
setCurrentMessageId(null);
};
const setDuration = (sec: number) => {
const el = audioRef.current;
if (!el) return;
isSeekingRef.current = true;
el.currentTime = Math.max(0, sec);
commitDuration(el.currentTime || 0);
};
return (
<PlayerContext.Provider
value={{
playAudio,
playing: isPlaying,
pause,
resume,
stop,
setDuration,
duration,
totalDuration,
currentMessageId,
lastMessageId,
lastError,
}}
>
{props.children}
<audio ref={audioRef} />
</PlayerContext.Provider>
);
}

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { PlayerContext, PlayerContextValue } from "./PlayerProvider";
export function usePlayerContext() : PlayerContextValue {
const context = useContext(PlayerContext);
if (!context) {
throw new Error("useAudioPlayer must be used within a PlayerProvider");
}
return context;
}

View File

@@ -6,7 +6,8 @@ export enum AttachmentType {
MESSAGES = 1,
FILE = 2,
AVATAR = 3,
CALL = 4
CALL = 4,
VOICE = 5
}
/**

View File

@@ -6,9 +6,14 @@ export enum SignalType {
KEY_EXCHANGE = 1,
ACTIVE_CALL = 2,
END_CALL = 3,
CREATE_ROOM = 4,
/**
* Переведен в стадию активного, значит комната на SFU уже создана и можно начинать обмен сигналами WebRTC
*/
ACTIVE = 4,
END_CALL_BECAUSE_PEER_DISCONNECTED = 5,
END_CALL_BECAUSE_BUSY = 6
END_CALL_BECAUSE_BUSY = 6,
ACCEPT = 7,
RINGING_TIMEOUT = 8
}
/**
@@ -28,12 +33,8 @@ export class PacketSignalPeer extends Packet {
private signalType: SignalType = SignalType.CALL;
/**
* Используется если SignalType == CREATE_ROOM,
* для идентификации комнаты на SFU сервере, в которой будет происходить обмен сигналами
* WebRTC для установления P2P соединения между участниками звонка
*/
private roomId: string = "";
private callId: string = "";
private joinToken: string = "";
public getPacketId(): number {
@@ -42,7 +43,9 @@ export class PacketSignalPeer extends Packet {
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){
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY
|| this.signalType == SignalType.RINGING_TIMEOUT
|| this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
return;
}
this.src = stream.readString();
@@ -50,8 +53,9 @@ export class PacketSignalPeer extends Packet {
if(this.signalType == SignalType.KEY_EXCHANGE){
this.sharedPublic = stream.readString();
}
if(this.signalType == SignalType.CREATE_ROOM){
this.roomId = stream.readString();
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
this.callId = stream.readString();
this.joinToken = stream.readString();
}
}
@@ -59,7 +63,9 @@ export class PacketSignalPeer extends Packet {
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){
if(this.signalType == SignalType.END_CALL_BECAUSE_BUSY
|| this.signalType == SignalType.RINGING_TIMEOUT
|| this.signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED){
return stream;
}
stream.writeString(this.src);
@@ -67,8 +73,9 @@ export class PacketSignalPeer extends Packet {
if(this.signalType == SignalType.KEY_EXCHANGE){
stream.writeString(this.sharedPublic);
}
if(this.signalType == SignalType.CREATE_ROOM){
stream.writeString(this.roomId);
if(this.signalType == SignalType.CALL || this.signalType == SignalType.ACCEPT || this.signalType == SignalType.END_CALL){
stream.writeString(this.callId);
stream.writeString(this.joinToken);
}
return stream;
}
@@ -105,12 +112,20 @@ export class PacketSignalPeer extends Packet {
this.src = src;
}
public getRoomId(): string {
return this.roomId;
public getCallId(): string {
return this.callId;
}
public setRoomId(roomId: string) {
this.roomId = roomId;
public setCallId(callId: string) {
this.callId = callId;
}
public getJoinToken(): string {
return this.joinToken;
}
public setJoinToken(joinToken: string) {
this.joinToken = joinToken;
}
}

View File

@@ -15,8 +15,6 @@ export class PacketWebRTC extends Packet {
private signalType: WebRTCSignalType = WebRTCSignalType.OFFER;
private sdpOrCandidate: string = "";
private publicKey: string = "";
private deviceId: string = "";
public getPacketId(): number {
return 27;
@@ -25,8 +23,6 @@ export class PacketWebRTC extends Packet {
public _receive(stream: Stream): void {
this.signalType = stream.readInt8();
this.sdpOrCandidate = stream.readString();
this.publicKey = stream.readString();
this.deviceId = stream.readString();
}
public _send(): Promise<Stream> | Stream {
@@ -34,8 +30,6 @@ export class PacketWebRTC extends Packet {
stream.writeInt16(this.getPacketId());
stream.writeInt8(this.signalType);
stream.writeString(this.sdpOrCandidate);
stream.writeString(this.publicKey);
stream.writeString(this.deviceId);
return stream;
}
@@ -55,20 +49,4 @@ export class PacketWebRTC extends Packet {
return this.sdpOrCandidate;
}
public setPublicKey(key: string) {
this.publicKey = key;
}
public getPublicKey(): string {
return this.publicKey;
}
public setDeviceId(id: string) {
this.deviceId = id;
}
public getDeviceId(): string {
return this.deviceId;
}
}

View File

@@ -1,8 +1,8 @@
export const SERVERS = [
//'wss://cdn.rosetta-im.com',
'ws://10.211.55.2:3000',
//'ws://10.211.55.2:3000',
//'ws://192.168.6.82:3000',
//'wss://wss.rosetta.im'
'wss://wss.rosetta.im'
];
export function selectServer(): string {

View File

@@ -17,6 +17,8 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
return "$a=Avatar";
case AttachmentType.CALL:
return "$a=Call";
case AttachmentType.VOICE:
return "$a=Voice message";
default:
return "[Unsupported attachment]";
}

View File

@@ -1,11 +1,7 @@
export const APP_VERSION = "1.1.7";
export const APP_VERSION = "1.2.2";
export const CORE_MIN_REQUIRED_VERSION = "1.5.5";
export const RELEASE_NOTICE = `
**Обновление v1.1.7** :emoji_1f631:
- Исправлен цвет аватарки при ее отсутствии в профиле
- Исправлены встречные звонки (состояние гонки)
- Защита от принятия уже принятого звонка
- Исправление системных звуков при звонках
- Время звонка начинается с момента фактического установления соединения с собеседником, а не с момента принятия звонка
**Обновление v1.2.2** :emoji_1f631:
- Поддержка записи и прослушивания голосовых сообщений
`;

View File

@@ -1,4 +1,4 @@
import { ColorSwatch, Text, useComputedColorScheme } from "@mantine/core";
import { Button, ColorSwatch, Flex, Text, useComputedColorScheme } from "@mantine/core";
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
import { useState } from "react";
import { Breadcrumbs } from "@/app/components/Breadcrumbs/Breadcrumbs";
@@ -17,6 +17,7 @@ import { SettingsIcon } from "@/app/components/SettingsIcon/SettingsIcon";
import { IconBrush, IconHomeCog, IconLogout, IconRefresh } from "@tabler/icons-react";
import { useLogout } from "@/app/providers/AccountProvider/useLogout";
import { RosettaPower } from "@/app/components/RosettaPower/RosettaPower";
import { modals } from "@mantine/modals";
export function MyProfile() {
const publicKey = usePublicKey();
@@ -28,8 +29,34 @@ export function MyProfile() {
const navigate = useNavigate();
const send = useSender();
const logout = useLogout();
const [usernameError, setUsernameError] = useState(false);
const openProfileModal = (text : string) => {
modals.open({
centered: true,
children: (
<>
<Text size="sm">
{text}
</Text>
<Flex align={'center'} justify={'flex-end'}>
<Button style={{
outline: 'none'
}} color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
Close
</Button>
</Flex>
</>
),
withCloseButton: false
});
}
const saveProfileData = () => {
if(usernameError) {
openProfileModal("You enter invalid username. Username must be a latin chars in lowercase.");
return;
}
let packet = new PacketUserInfo();
packet.setUsername(username);
packet.setTitle(title);
@@ -70,10 +97,13 @@ export function MyProfile() {
<SettingsInput.Default
hit="Username"
value={username}
onErrorStateChange={(error) => setUsernameError(error)}
placeholder="ex. freddie871"
onChange={(e) => setUsername(e.target.value)}
regexp={new RegExp(/^([a-z][a-z0-9_]{4,15})?$/)}
></SettingsInput.Default>
</SettingsInput.Group>
{usernameError && <Text c={'red'} fz={10} pl={'xs'} mt={3}>Invalid username.</Text>}
<SettingsInput.Copy mt={'sm'} hit="Public Key" value={
publicKey
} placeholder="Public"></SettingsInput.Copy>