Compare commits
16 Commits
779c265851
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d5980a453 | |||
| 5193ceb071 | |||
| 7434e16252 | |||
| c762e527c2 | |||
| 3291def79b | |||
| 032f92f8a2 | |||
|
|
f4592d03b0 | ||
|
|
6554483939 | ||
|
|
ba12db3c72 | ||
|
|
b596d36543 | ||
|
|
93ef692eb5 | ||
|
|
8fdfe9b786 | ||
|
|
547ac89987 | ||
|
|
130ad9c35a | ||
|
|
adfc6add6f | ||
|
|
cf29cecfd6 |
@@ -22,6 +22,7 @@ import { DialogStateProvider } from './providers/DialogStateProvider.tsx/DialogS
|
|||||||
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
|
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
|
||||||
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
|
import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider';
|
||||||
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
|
import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider';
|
||||||
|
import { PlayerProvider } from './providers/PlayerProvider/PlayerProvider';
|
||||||
window.Buffer = Buffer;
|
window.Buffer = Buffer;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -58,6 +59,7 @@ export default function App() {
|
|||||||
<Topbar></Topbar>
|
<Topbar></Topbar>
|
||||||
<ContextMenuProvider>
|
<ContextMenuProvider>
|
||||||
<ImageViwerProvider>
|
<ImageViwerProvider>
|
||||||
|
<PlayerProvider>
|
||||||
<AvatarProvider>
|
<AvatarProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
@@ -71,6 +73,7 @@ export default function App() {
|
|||||||
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
|
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AvatarProvider>
|
</AvatarProvider>
|
||||||
|
</PlayerProvider>
|
||||||
</ImageViwerProvider>
|
</ImageViwerProvider>
|
||||||
</ContextMenuProvider>
|
</ContextMenuProvider>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||||
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
|
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
|
||||||
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react";
|
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMicrophone, IconMoodSmile, IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
||||||
import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
|
import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
|
||||||
@@ -25,7 +25,8 @@ import { AnimatedButton } from "../AnimatedButton/AnimatedButton";
|
|||||||
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
|
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
|
||||||
import { MentionList, Mention } from "../MentionList/MentionList";
|
import { MentionList, Mention } from "../MentionList/MentionList";
|
||||||
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
|
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
|
||||||
|
import { useVoiceMessage } from "./useVoiceMessage";
|
||||||
|
import { VoiceRecorder } from "../VoiceRecorder/VoiceRecorder";
|
||||||
|
|
||||||
export function DialogInput() {
|
export function DialogInput() {
|
||||||
const colors = useRosettaColors();
|
const colors = useRosettaColors();
|
||||||
@@ -47,6 +48,7 @@ export function DialogInput() {
|
|||||||
const [mentionList, setMentionList] = useState<Mention[]>([]);
|
const [mentionList, setMentionList] = useState<Mention[]>([]);
|
||||||
const mentionHandling = useRef<string>("");
|
const mentionHandling = useRef<string>("");
|
||||||
const {getDraft, saveDraft} = useDrafts(dialog);
|
const {getDraft, saveDraft} = useDrafts(dialog);
|
||||||
|
const {start, stop, isRecording, duration, waves, getAudioBlob, interpolateCompressWaves} = useVoiceMessage();
|
||||||
|
|
||||||
|
|
||||||
const avatars = useAvatars(
|
const avatars = useAvatars(
|
||||||
@@ -64,6 +66,15 @@ export function DialogInput() {
|
|||||||
}]
|
}]
|
||||||
], [], true);
|
], [], true);
|
||||||
|
|
||||||
|
const hasText = message.trim().length > 0;
|
||||||
|
const showSendIcon = hasText || attachments.length > 0 || isRecording;
|
||||||
|
|
||||||
|
const onMicroClick = () => {
|
||||||
|
if(!isRecording) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fileDialog = useFileDialog({
|
const fileDialog = useFileDialog({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
//naccept: '*',
|
//naccept: '*',
|
||||||
@@ -188,8 +199,28 @@ export function DialogInput() {
|
|||||||
mentionHandling.current = username;
|
mentionHandling.current = username;
|
||||||
}
|
}
|
||||||
|
|
||||||
const send = () => {
|
const send = async () => {
|
||||||
if(blocked || (message.trim() == "" && attachments.length <= 0)) {
|
if(blocked || (message.trim() == "" && attachments.length <= 0 && !isRecording)){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(isRecording){
|
||||||
|
const audioBlob = getAudioBlob();
|
||||||
|
stop();
|
||||||
|
if(!audioBlob){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendMessage("", [
|
||||||
|
{
|
||||||
|
blob: Buffer.from(await audioBlob.arrayBuffer()).toString('hex'),
|
||||||
|
id: generateRandomKey(8),
|
||||||
|
type: AttachmentType.VOICE,
|
||||||
|
preview: duration + "::" + interpolateCompressWaves(35).join(","),
|
||||||
|
transport: {
|
||||||
|
transport_server: "",
|
||||||
|
transport_tag: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendMessage(message, attachments);
|
sendMessage(message, attachments);
|
||||||
@@ -365,6 +396,12 @@ export function DialogInput() {
|
|||||||
{!blocked &&
|
{!blocked &&
|
||||||
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
|
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
|
||||||
<Flex w={25} mt={10} justify={'center'}>
|
<Flex w={25} mt={10} justify={'center'}>
|
||||||
|
{isRecording && (
|
||||||
|
<IconTrash onClick={stop} style={{
|
||||||
|
cursor: 'pointer'
|
||||||
|
}} color={colors.error} stroke={1.5} size={25}></IconTrash>
|
||||||
|
)}
|
||||||
|
{!isRecording && (
|
||||||
<Menu width={150} withArrow>
|
<Menu width={150} withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<IconPaperclip stroke={1.5} style={{
|
<IconPaperclip stroke={1.5} style={{
|
||||||
@@ -385,12 +422,14 @@ export function DialogInput() {
|
|||||||
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
|
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex
|
<Flex
|
||||||
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||||
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||||
align={'center'}
|
align={'center'}
|
||||||
>
|
>
|
||||||
|
{!isRecording && <>
|
||||||
<RichTextInput
|
<RichTextInput
|
||||||
ref={editableDivRef}
|
ref={editableDivRef}
|
||||||
style={{
|
style={{
|
||||||
@@ -407,19 +446,17 @@ export function DialogInput() {
|
|||||||
}}
|
}}
|
||||||
placeholder="Type message..."
|
placeholder="Type message..."
|
||||||
autoFocus
|
autoFocus
|
||||||
//ref={textareaRef}
|
|
||||||
//onPaste={onPaste}
|
|
||||||
//maxLength={2500}
|
|
||||||
//w={'100%'}
|
|
||||||
//h={'100%'}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onChange={setMessage}
|
onChange={setMessage}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
|
|
||||||
//dangerouslySetInnerHTML={{__html: message}}
|
|
||||||
></RichTextInput>
|
></RichTextInput>
|
||||||
|
</>}
|
||||||
|
{isRecording && <>
|
||||||
|
<VoiceRecorder duration={duration} waves={waves}></VoiceRecorder>
|
||||||
|
</>}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
|
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
|
||||||
|
{!isRecording && <>
|
||||||
<Popover withArrow>
|
<Popover withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
|
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
|
||||||
@@ -435,9 +472,42 @@ export function DialogInput() {
|
|||||||
/>
|
/>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
<IconSend stroke={1.5} color={message.trim() == "" && attachments.length <= 0 ? colors.chevrons.active : colors.brandColor} onClick={send} style={{
|
</>}
|
||||||
|
<Box pos="relative" ml={isRecording ? 35 : 0} w={25} h={25}>
|
||||||
|
<Transition mounted={showSendIcon} transition="pop" duration={180} timingFunction="ease">
|
||||||
|
{(styles) => (
|
||||||
|
<IconSend
|
||||||
|
stroke={1.5}
|
||||||
|
color={colors.brandColor}
|
||||||
|
onClick={send}
|
||||||
|
style={{
|
||||||
|
...styles,
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}} size={25}></IconSend>
|
}}
|
||||||
|
size={25}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition mounted={!showSendIcon} transition="pop" duration={180} timingFunction="ease">
|
||||||
|
{(styles) => (
|
||||||
|
<IconMicrophone
|
||||||
|
stroke={1.5}
|
||||||
|
color={colors.chevrons.active}
|
||||||
|
onClick={onMicroClick}
|
||||||
|
style={{
|
||||||
|
...styles,
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
size={25}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>}
|
</Flex>}
|
||||||
{blocked && <Box mih={62} bg={colors.boxColor}>
|
{blocked && <Box mih={62} bg={colors.boxColor}>
|
||||||
|
|||||||
273
app/components/DialogInput/useVoiceMessage.ts
Normal file
273
app/components/DialogInput/useVoiceMessage.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useVoiceMessage(): {
|
||||||
|
isRecording: boolean;
|
||||||
|
isPaused: boolean;
|
||||||
|
duration: number;
|
||||||
|
waves: number[];
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
play: () => void;
|
||||||
|
error: string | null;
|
||||||
|
getAudioBlob: () => Blob | null;
|
||||||
|
interpolateCompressWaves: (targetLength: number) => number[];
|
||||||
|
} {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [waves, setWaves] = useState<number[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const waveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
|
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
const waveDataRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
|
||||||
|
|
||||||
|
const clearTimer = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopWaveLoop = useCallback(() => {
|
||||||
|
if (waveTimerRef.current) {
|
||||||
|
clearInterval(waveTimerRef.current);
|
||||||
|
waveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTimer = useCallback(() => {
|
||||||
|
if (timerRef.current) return;
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setDuration((prev) => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startWaveLoop = useCallback(() => {
|
||||||
|
stopWaveLoop();
|
||||||
|
|
||||||
|
const analyser = analyserRef.current;
|
||||||
|
if (!analyser) return;
|
||||||
|
|
||||||
|
if (!waveDataRef.current || waveDataRef.current.length !== analyser.frequencyBinCount) {
|
||||||
|
waveDataRef.current = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_WAVES = 120;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!analyserRef.current || !waveDataRef.current) return;
|
||||||
|
|
||||||
|
analyserRef.current.getByteFrequencyData(waveDataRef.current);
|
||||||
|
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < waveDataRef.current.length; i++) {
|
||||||
|
const v = waveDataRef.current[i];
|
||||||
|
if (v > peak) peak = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bar = peak / 255;
|
||||||
|
|
||||||
|
setWaves((prev) => {
|
||||||
|
const next = [...prev, bar];
|
||||||
|
return next.length > MAX_WAVES ? next.slice(next.length - MAX_WAVES) : next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
waveTimerRef.current = setInterval(tick, 300);
|
||||||
|
}, [stopWaveLoop]);
|
||||||
|
|
||||||
|
const cleanupAudio = useCallback(() => {
|
||||||
|
stopWaveLoop();
|
||||||
|
|
||||||
|
sourceRef.current?.disconnect();
|
||||||
|
sourceRef.current = null;
|
||||||
|
analyserRef.current = null;
|
||||||
|
waveDataRef.current = null;
|
||||||
|
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
audioContextRef.current.close();
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}, [stopWaveLoop]);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setDuration(0);
|
||||||
|
setWaves([]);
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 2048;
|
||||||
|
analyser.smoothingTimeConstant = 0;
|
||||||
|
analyser.minDecibels = -100;
|
||||||
|
analyser.maxDecibels = -10;
|
||||||
|
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
analyserRef.current = analyser;
|
||||||
|
sourceRef.current = source;
|
||||||
|
|
||||||
|
// Выбираем лучший поддерживаемый кодек
|
||||||
|
const preferredTypes = [
|
||||||
|
"audio/webm;codecs=opus",
|
||||||
|
"audio/ogg;codecs=opus",
|
||||||
|
"audio/webm",
|
||||||
|
];
|
||||||
|
|
||||||
|
const mimeType = preferredTypes.find((t) => MediaRecorder.isTypeSupported(t)) ?? "";
|
||||||
|
|
||||||
|
const mediaRecorder = new MediaRecorder(stream, {
|
||||||
|
...(mimeType ? { mimeType } : {}),
|
||||||
|
audioBitsPerSecond: 32_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
cleanupAudio();
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start(100);
|
||||||
|
setIsRecording(true);
|
||||||
|
setIsPaused(false);
|
||||||
|
startTimer();
|
||||||
|
startWaveLoop();
|
||||||
|
} catch (err) {
|
||||||
|
setError("Could not start voice recording. Please check microphone permissions.");
|
||||||
|
console.error("Voice recording error:", err);
|
||||||
|
}
|
||||||
|
}, [startTimer, startWaveLoop, cleanupAudio]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
mediaRecorderRef.current = null;
|
||||||
|
setIsRecording(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
clearTimer();
|
||||||
|
stopWaveLoop();
|
||||||
|
}
|
||||||
|
}, [isRecording, clearTimer, stopWaveLoop]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
||||||
|
mediaRecorderRef.current.pause();
|
||||||
|
setIsPaused(true);
|
||||||
|
clearTimer();
|
||||||
|
stopWaveLoop();
|
||||||
|
}
|
||||||
|
}, [clearTimer, stopWaveLoop]);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "paused") {
|
||||||
|
mediaRecorderRef.current.resume();
|
||||||
|
setIsPaused(false);
|
||||||
|
startTimer();
|
||||||
|
startWaveLoop();
|
||||||
|
}
|
||||||
|
}, [startTimer, startWaveLoop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimer();
|
||||||
|
stopWaveLoop();
|
||||||
|
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current?.state !== "inactive") {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupAudio();
|
||||||
|
};
|
||||||
|
}, [clearTimer, stopWaveLoop, cleanupAudio]);
|
||||||
|
|
||||||
|
const getAudioBlob = useCallback((): Blob | null => {
|
||||||
|
if (chunksRef.current.length === 0) return null;
|
||||||
|
const mimeType = mediaRecorderRef.current?.mimeType ?? "audio/webm;codecs=opus";
|
||||||
|
return new Blob(chunksRef.current, { type: mimeType });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const interpolateCompressWaves = useCallback((targetLength: number) => {
|
||||||
|
if (targetLength <= 0) return [];
|
||||||
|
if (waves.length === 0) return Array(targetLength).fill(0);
|
||||||
|
if (waves.length === targetLength) return waves;
|
||||||
|
|
||||||
|
if (waves.length > targetLength) {
|
||||||
|
const compressed: number[] = [];
|
||||||
|
const bucketSize = waves.length / targetLength;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetLength; i++) {
|
||||||
|
const start = Math.floor(i * bucketSize);
|
||||||
|
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
|
||||||
|
|
||||||
|
let max = 0;
|
||||||
|
for (let j = start; j < end && j < waves.length; j++) {
|
||||||
|
if (waves[j] > max) max = waves[j];
|
||||||
|
}
|
||||||
|
|
||||||
|
compressed.push(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLength === 1) return [waves[0]];
|
||||||
|
|
||||||
|
const stretched: number[] = [];
|
||||||
|
const lastSourceIndex = waves.length - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetLength; i++) {
|
||||||
|
const position = (i * lastSourceIndex) / (targetLength - 1);
|
||||||
|
const left = Math.floor(position);
|
||||||
|
const right = Math.min(Math.ceil(position), lastSourceIndex);
|
||||||
|
|
||||||
|
if (left === right) {
|
||||||
|
stretched.push(waves[left]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = position - left;
|
||||||
|
const value = waves[left] * (1 - t) + waves[right] * t;
|
||||||
|
stretched.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stretched;
|
||||||
|
}, [waves]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecording,
|
||||||
|
isPaused,
|
||||||
|
duration,
|
||||||
|
waves,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
pause,
|
||||||
|
play,
|
||||||
|
error,
|
||||||
|
getAudioBlob,
|
||||||
|
interpolateCompressWaves,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu";
|
|||||||
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
|
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
|
||||||
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
|
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
|
||||||
import { useMentions } from "@/app/providers/DialogStateProvider.tsx/useMentions";
|
import { useMentions } from "@/app/providers/DialogStateProvider.tsx/useMentions";
|
||||||
|
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||||
|
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||||
|
|
||||||
export interface DialogProps extends DialogRow {
|
export interface DialogProps extends DialogRow {
|
||||||
onClickDialog: (dialog: string) => void;
|
onClickDialog: (dialog: string) => void;
|
||||||
@@ -54,6 +56,7 @@ export function GroupDialog(props : DialogProps) {
|
|||||||
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
|
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
|
||||||
const {openContextMenu} = useDialogContextMenu();
|
const {openContextMenu} = useDialogContextMenu();
|
||||||
const {isMentioned} = useMentions();
|
const {isMentioned} = useMentions();
|
||||||
|
const [protocolState] = useProtocolState();
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,7 +159,7 @@ export function GroupDialog(props : DialogProps) {
|
|||||||
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
|
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||||
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
|
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
|
||||||
)}
|
)}
|
||||||
{unreaded > 0 && !lastMessageFromMe && !isMentioned(props.dialog_id) && <Badge
|
{unreaded > 0 && !lastMessageFromMe && protocolState != ProtocolState.SYNCHRONIZATION && !isMentioned(props.dialog_id) && <Badge
|
||||||
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
|
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
|
||||||
c={isInCurrentDialog ? colors.brandColor : 'white'}
|
c={isInCurrentDialog ? colors.brandColor : 'white'}
|
||||||
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}
|
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AttachmentError } from "../AttachmentError/AttachmentError";
|
|||||||
import { MessageAvatar } from "./MessageAvatar";
|
import { MessageAvatar } from "./MessageAvatar";
|
||||||
import { MessageProps } from "../Messages/Message";
|
import { MessageProps } from "../Messages/Message";
|
||||||
import { MessageCall } from "./MessageCall";
|
import { MessageCall } from "./MessageCall";
|
||||||
|
import { MessageVoice } from "./MessageVoice";
|
||||||
|
|
||||||
export interface MessageAttachmentsProps {
|
export interface MessageAttachmentsProps {
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
@@ -54,6 +55,8 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
|
|||||||
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
||||||
case AttachmentType.CALL:
|
case AttachmentType.CALL:
|
||||||
return <MessageCall {...attachProps} key={index}></MessageCall>
|
return <MessageCall {...attachProps} key={index}></MessageCall>
|
||||||
|
case AttachmentType.VOICE:
|
||||||
|
return <MessageVoice {...attachProps} key={index}></MessageVoice>
|
||||||
default:
|
default:
|
||||||
return <AttachmentError key={index}></AttachmentError>;
|
return <AttachmentError key={index}></AttachmentError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function MessageImage(props: AttachmentProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Portal>}
|
</Portal>}
|
||||||
{(props.delivered == DeliveredMessageState.ERROR || (props.delivered != DeliveredMessageState.DELIVERED &&
|
{(props.delivered == DeliveredMessageState.ERROR || error || (props.delivered != DeliveredMessageState.DELIVERED &&
|
||||||
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
|
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
|
||||||
)) && (
|
)) && (
|
||||||
<Overlay center h={'100%'} radius={8} color="#000" opacity={0.85}>
|
<Overlay center h={'100%'} radius={8} color="#000" opacity={0.85}>
|
||||||
|
|||||||
267
app/components/MessageAttachments/MessageVoice.tsx
Normal file
267
app/components/MessageAttachments/MessageVoice.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
|
||||||
|
import { AttachmentProps } from "./MessageAttachments";
|
||||||
|
import { Avatar, Box, Flex, Text, useMantineTheme } from "@mantine/core";
|
||||||
|
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
|
||||||
|
import { IconArrowDown, IconPlayerPauseFilled, IconPlayerPlayFilled, IconX } from "@tabler/icons-react";
|
||||||
|
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||||
|
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { usePlayerContext } from "@/app/providers/PlayerProvider/usePlayerContext";
|
||||||
|
|
||||||
|
const WAVE_BARS = 40;
|
||||||
|
const BAR_WIDTH = 2;
|
||||||
|
const BAR_GAP = 2;
|
||||||
|
const MIN_BAR_HEIGHT = 4;
|
||||||
|
const MAX_BAR_HEIGHT = 24;
|
||||||
|
|
||||||
|
function normalizeWaves(source: number[], targetLength: number): number[] {
|
||||||
|
if (targetLength <= 0) return [];
|
||||||
|
if (source.length === 0) return Array(targetLength).fill(0);
|
||||||
|
if (source.length === targetLength) return source;
|
||||||
|
|
||||||
|
if (source.length > targetLength) {
|
||||||
|
const compressed: number[] = [];
|
||||||
|
const bucketSize = source.length / targetLength;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetLength; i++) {
|
||||||
|
const start = Math.floor(i * bucketSize);
|
||||||
|
const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize));
|
||||||
|
|
||||||
|
let max = 0;
|
||||||
|
for (let j = start; j < end && j < source.length; j++) {
|
||||||
|
if (source[j] > max) max = source[j];
|
||||||
|
}
|
||||||
|
|
||||||
|
compressed.push(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLength === 1) return [source[0]];
|
||||||
|
|
||||||
|
const stretched: number[] = [];
|
||||||
|
const lastSourceIndex = source.length - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetLength; i++) {
|
||||||
|
const position = (i * lastSourceIndex) / (targetLength - 1);
|
||||||
|
const left = Math.floor(position);
|
||||||
|
const right = Math.min(Math.ceil(position), lastSourceIndex);
|
||||||
|
|
||||||
|
if (left === right) {
|
||||||
|
stretched.push(source[left]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = position - left;
|
||||||
|
stretched.push(source[left] * (1 - t) + source[right] * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stretched;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
const s = Math.max(0, Math.floor(seconds));
|
||||||
|
const m = Math.floor(s / 60).toString().padStart(2, "0");
|
||||||
|
const r = (s % 60).toString().padStart(2, "0");
|
||||||
|
return `${m}:${r}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageVoice(props: AttachmentProps) {
|
||||||
|
const { downloadPercentage, downloadStatus, uploadedPercentage, download, getPreview } = useAttachment(
|
||||||
|
props.attachment,
|
||||||
|
props.parent,
|
||||||
|
);
|
||||||
|
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const colors = useRosettaColors();
|
||||||
|
|
||||||
|
const preview = getPreview() || "";
|
||||||
|
const [durationPart = "0", wavesPart = ""] = preview.split("::");
|
||||||
|
const previewDuration = Number.parseInt(durationPart, 10) || 0;
|
||||||
|
|
||||||
|
const rawWaves = useMemo(
|
||||||
|
() =>
|
||||||
|
wavesPart
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number.parseFloat(s))
|
||||||
|
.filter((n) => Number.isFinite(n) && n >= 0),
|
||||||
|
[wavesPart]
|
||||||
|
);
|
||||||
|
|
||||||
|
const waves = useMemo(() => normalizeWaves(rawWaves, WAVE_BARS), [rawWaves]);
|
||||||
|
|
||||||
|
const peak = useMemo(() => {
|
||||||
|
const max = Math.max(...waves, 0);
|
||||||
|
return max > 0 ? max : 1;
|
||||||
|
}, [waves]);
|
||||||
|
|
||||||
|
const isUploading =
|
||||||
|
props.delivered === DeliveredMessageState.WAITING &&
|
||||||
|
uploadedPercentage > 0 &&
|
||||||
|
uploadedPercentage < 100;
|
||||||
|
const error = downloadStatus === DownloadStatus.ERROR;
|
||||||
|
|
||||||
|
const waveformWidth = WAVE_BARS * BAR_WIDTH + (WAVE_BARS - 1) * BAR_GAP;
|
||||||
|
const waveformRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
playAudio,
|
||||||
|
pause,
|
||||||
|
duration: currentDuration,
|
||||||
|
playing,
|
||||||
|
setDuration,
|
||||||
|
totalDuration,
|
||||||
|
currentMessageId,
|
||||||
|
} = usePlayerContext();
|
||||||
|
const messageId = String((props.parent as any)?.id ?? (props.attachment as any)?.messageId ?? props.attachment.id);
|
||||||
|
const isCurrentTrack = currentMessageId === messageId;
|
||||||
|
|
||||||
|
const fullDuration = Math.max(isCurrentTrack && totalDuration > 0 ? totalDuration : previewDuration, 1);
|
||||||
|
const safeCurrent = isCurrentTrack ? currentDuration : 0;
|
||||||
|
const playbackProgress = Math.max(0, Math.min(1, safeCurrent / fullDuration));
|
||||||
|
|
||||||
|
const createAudioBlob = () => new Blob([Buffer.from(props.attachment.blob, "hex")], { type: "audio/webm;codecs=opus" });
|
||||||
|
|
||||||
|
const ensureStarted = (seekToSec?: number) => {
|
||||||
|
const blob = createAudioBlob();
|
||||||
|
playAudio("Voice Message", "", blob, messageId);
|
||||||
|
|
||||||
|
if (typeof seekToSec === "number") {
|
||||||
|
requestAnimationFrame(() => setDuration(seekToSec));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMainAction = () => {
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
if (downloadStatus !== DownloadStatus.DOWNLOADED) {
|
||||||
|
download();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentTrack) {
|
||||||
|
ensureStarted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureStarted(Math.max(0, safeCurrent));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (error || downloadStatus !== DownloadStatus.DOWNLOADED) return;
|
||||||
|
|
||||||
|
const rect = waveformRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect || rect.width <= 0) return;
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const progress = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const seekTo = progress * fullDuration;
|
||||||
|
|
||||||
|
if (!isCurrentTrack) {
|
||||||
|
ensureStarted(seekTo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDuration(seekTo);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeText =
|
||||||
|
isCurrentTrack && safeCurrent > 0
|
||||||
|
? `-${formatTime(Math.max(0, fullDuration - safeCurrent))}`
|
||||||
|
: formatTime(fullDuration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="sm" align="center">
|
||||||
|
<Avatar
|
||||||
|
bg={error ? colors.error : colors.brandColor}
|
||||||
|
size={40}
|
||||||
|
style={{ cursor: "pointer", position: "relative" }}
|
||||||
|
onClick={handleMainAction}
|
||||||
|
>
|
||||||
|
{!error && (
|
||||||
|
<>
|
||||||
|
{downloadStatus === DownloadStatus.DOWNLOADING && (
|
||||||
|
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||||
|
<AnimatedRoundedProgress size={40} value={Math.max(1, downloadPercentage)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||||
|
<AnimatedRoundedProgress color="#fff" size={40} value={uploadedPercentage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{downloadStatus !== DownloadStatus.DOWNLOADED && <IconArrowDown color="white" size={22} />}
|
||||||
|
|
||||||
|
{downloadStatus === DownloadStatus.DOWNLOADED && !isUploading &&
|
||||||
|
(isCurrentTrack && playing ? (
|
||||||
|
<IconPlayerPauseFilled color="white" size={22} />
|
||||||
|
) : (
|
||||||
|
<IconPlayerPlayFilled color="white" size={22} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || isUploading) && <IconX color="white" size={22} />}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Flex direction="column">
|
||||||
|
<Box
|
||||||
|
ref={waveformRef}
|
||||||
|
w={waveformWidth}
|
||||||
|
h={32}
|
||||||
|
onClick={handleSeek}
|
||||||
|
style={{ overflow: "hidden", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<Flex h="100%" align="center" gap={BAR_GAP} wrap="nowrap">
|
||||||
|
{waves.map((value, index) => {
|
||||||
|
const normalized = Math.max(0, Math.min(1, value / peak));
|
||||||
|
const height = Math.max(
|
||||||
|
MIN_BAR_HEIGHT,
|
||||||
|
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
|
||||||
|
);
|
||||||
|
|
||||||
|
const passed = playbackProgress * waves.length - index;
|
||||||
|
const fillPercent = Math.max(0, Math.min(1, passed));
|
||||||
|
|
||||||
|
const inactiveColor = theme.colors.gray[4];
|
||||||
|
const activeColor = colors.brandColor;
|
||||||
|
|
||||||
|
let background = inactiveColor;
|
||||||
|
if (fillPercent >= 1) {
|
||||||
|
background = activeColor;
|
||||||
|
} else if (fillPercent > 0) {
|
||||||
|
background = `linear-gradient(90deg, ${activeColor} 0%, ${activeColor} ${fillPercent * 100}%, ${inactiveColor} ${fillPercent * 100}%, ${inactiveColor} 100%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={index}
|
||||||
|
w={BAR_WIDTH}
|
||||||
|
h={height}
|
||||||
|
style={{
|
||||||
|
flex: `0 0 ${BAR_WIDTH}px`,
|
||||||
|
borderRadius: 999,
|
||||||
|
background,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{timeText}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ export interface SettingsInputDefaultProps {
|
|||||||
mt?: StyleProp<MantineSpacing>;
|
mt?: StyleProp<MantineSpacing>;
|
||||||
rightSection?: ReactNode;
|
rightSection?: ReactNode;
|
||||||
type?: HTMLInputTypeAttribute;
|
type?: HTMLInputTypeAttribute;
|
||||||
|
onErrorStateChange?: (error: boolean) => void;
|
||||||
|
regexp?: RegExp;
|
||||||
}
|
}
|
||||||
export interface SettingsInputGroupProps {
|
export interface SettingsInputGroupProps {
|
||||||
mt?: StyleProp<MantineSpacing>;
|
mt?: StyleProp<MantineSpacing>;
|
||||||
@@ -260,7 +262,6 @@ function SettingsInputClickable(
|
|||||||
function SettingsInputDefault(props : SettingsInputDefaultProps) {
|
function SettingsInputDefault(props : SettingsInputDefaultProps) {
|
||||||
const colors = useRosettaColors();
|
const colors = useRosettaColors();
|
||||||
const input = useRef<any>(undefined);
|
const input = useRef<any>(undefined);
|
||||||
|
|
||||||
const onClick = (e : MouseEvent) => {
|
const onClick = (e : MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if(!props.disabled){
|
if(!props.disabled){
|
||||||
@@ -268,6 +269,19 @@ function SettingsInputDefault(props : SettingsInputDefaultProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
let value = e.target.value;
|
||||||
|
if(props.regexp && !props.regexp.test(value)) {
|
||||||
|
props.onErrorStateChange && props.onErrorStateChange(true);
|
||||||
|
props.onChange && props.onChange(e);
|
||||||
|
}else{
|
||||||
|
props.onErrorStateChange && props.onErrorStateChange(false);
|
||||||
|
console.info('fa');
|
||||||
|
props.onChange && props.onChange(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (<>
|
return (<>
|
||||||
<Paper mt={props.mt} style={props.style} withBorder styles={{
|
<Paper mt={props.mt} style={props.style} withBorder styles={{
|
||||||
root: {
|
root: {
|
||||||
@@ -298,7 +312,7 @@ function SettingsInputDefault(props : SettingsInputDefaultProps) {
|
|||||||
{!props.rightSection && (
|
{!props.rightSection && (
|
||||||
<Input type={props.type} defaultValue={!props.onChange ? props.value : undefined} value={!props.onChange ? undefined : props.value} ref={input} disabled={props.disabled} onClick={(e) => {
|
<Input type={props.type} defaultValue={!props.onChange ? props.value : undefined} value={!props.onChange ? undefined : props.value} ref={input} disabled={props.disabled} onClick={(e) => {
|
||||||
onClick(e)
|
onClick(e)
|
||||||
}} onChange={props.onChange} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
|
}} onChange={onChange} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
|
||||||
input: classes.input
|
input: classes.input
|
||||||
}} placeholder={props.placeholder}></Input>)
|
}} placeholder={props.placeholder}></Input>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export function TextParser(props: TextParserProps) {
|
|||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
let entityCount = 0;
|
let entityCount = 0;
|
||||||
|
|
||||||
|
const UNICODE_EMOJI_SEQUENCE_REGEX =
|
||||||
|
/(?:\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
|
||||||
|
|
||||||
|
const UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL = new RegExp(
|
||||||
|
UNICODE_EMOJI_SEQUENCE_REGEX.source,
|
||||||
|
"gu"
|
||||||
|
);
|
||||||
|
|
||||||
|
const toUnified = (value: string): string =>
|
||||||
|
Array.from(value)
|
||||||
|
.map((ch) => ch.codePointAt(0)?.toString(16))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("-");
|
||||||
|
|
||||||
const formatRules : FormatRule[] = [
|
const formatRules : FormatRule[] = [
|
||||||
{
|
{
|
||||||
pattern: [
|
pattern: [
|
||||||
@@ -58,7 +72,7 @@ export function TextParser(props: TextParserProps) {
|
|||||||
return <Anchor style={{
|
return <Anchor style={{
|
||||||
userSelect: 'auto',
|
userSelect: 'auto',
|
||||||
color: props.__reserved_2 ? theme.colors.blue[2] : undefined
|
color: props.__reserved_2 ? theme.colors.blue[2] : undefined
|
||||||
}} fz={14} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
|
}} fz={13} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
|
||||||
},
|
},
|
||||||
flush: (match: string) => {
|
flush: (match: string) => {
|
||||||
return <>{match}</>;
|
return <>{match}</>;
|
||||||
@@ -120,17 +134,20 @@ export function TextParser(props: TextParserProps) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// unicode emojis
|
// unicode emojis (including composite sequences)
|
||||||
pattern: [/\p{Emoji_Presentation}/u],
|
pattern: [UNICODE_EMOJI_SEQUENCE_REGEX],
|
||||||
render: (match: string) => {
|
render: (match: string) => {
|
||||||
let textWithoutEmojis = props.text.replace(/\p{Emoji_Presentation}/gu, '');
|
const textWithoutEmojis = props.text.replace(UNICODE_EMOJI_SEQUENCE_REGEX_GLOBAL, "");
|
||||||
|
const unified = toUnified(match);
|
||||||
|
|
||||||
if (textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
|
if (textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
|
||||||
return <Emoji size={40} unified={match.codePointAt(0)?.toString(16) || ''}></Emoji>;
|
return <Emoji size={40} unified={unified}></Emoji>;
|
||||||
}
|
}
|
||||||
return <Emoji unified={match.codePointAt(0)?.toString(16) || ''}></Emoji>;
|
|
||||||
|
return <Emoji unified={unified}></Emoji>;
|
||||||
},
|
},
|
||||||
flush: (match: string) => {
|
flush: (match: string) => {
|
||||||
return <Emoji unified={match.codePointAt(0)?.toString(16) || ''}></Emoji>;
|
return <Emoji unified={toUnified(match)}></Emoji>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,15 +5,23 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useUserInformation } from '@/app/providers/InformationProvider/useUserInformation';
|
import { useUserInformation } from '@/app/providers/InformationProvider/useUserInformation';
|
||||||
import { usePublicKey } from '@/app/providers/AccountProvider/usePublicKey';
|
import { usePublicKey } from '@/app/providers/AccountProvider/usePublicKey';
|
||||||
import { useAvatars } from '@/app/providers/AvatarProvider/useAvatars';
|
import { useAvatars } from '@/app/providers/AvatarProvider/useAvatars';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export function UserButton() {
|
export function UserButton() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const publicKey = usePublicKey();
|
const publicKey = usePublicKey();
|
||||||
const [userInfo] = useUserInformation(publicKey);
|
const [userInfo, _, forceUpdateUserInformation] = useUserInformation(publicKey);
|
||||||
const avatars = useAvatars(publicKey);
|
const avatars = useAvatars(publicKey);
|
||||||
|
|
||||||
const loading = userInfo.publicKey !== publicKey;
|
const loading = userInfo.publicKey !== publicKey;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* Обновляем информацию о пользователе принудительно при рендере левого меню
|
||||||
|
*/
|
||||||
|
forceUpdateUserInformation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton p={'sm'} className={classes.user} onClick={() => navigate("/main/profile/me")}>
|
<UnstyledButton p={'sm'} className={classes.user} onClick={() => navigate("/main/profile/me")}>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
278
app/components/VoiceRecorder/VoiceRecorder.tsx
Normal file
278
app/components/VoiceRecorder/VoiceRecorder.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { Box, Flex, Text, useMantineTheme } from "@mantine/core";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface VoiceRecorderProps {
|
||||||
|
duration: number;
|
||||||
|
waves: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnimatedBar = {
|
||||||
|
id: number;
|
||||||
|
value: number;
|
||||||
|
entered: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISIBLE_BARS = 50;
|
||||||
|
const BAR_WIDTH = 3;
|
||||||
|
const BAR_GAP = 2;
|
||||||
|
const STEP_PX = BAR_WIDTH + BAR_GAP;
|
||||||
|
const COMPONENT_HEIGHT = 45;
|
||||||
|
const MAX_BAR_HEIGHT = 28;
|
||||||
|
const MIN_BAR_HEIGHT = 4;
|
||||||
|
|
||||||
|
export function VoiceRecorder(props: VoiceRecorderProps) {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const [bars, setBars] = useState<AnimatedBar[]>([]);
|
||||||
|
const [subShift, setSubShift] = useState(0);
|
||||||
|
|
||||||
|
const prevLengthRef = useRef(0);
|
||||||
|
const prevWavesRef = useRef<number[]>([]);
|
||||||
|
const idRef = useRef(0);
|
||||||
|
const enterFrameRef = useRef<number | null>(null);
|
||||||
|
const scrollFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const lastAppendAtRef = useRef<number | null>(null);
|
||||||
|
const appendIntervalRef = useRef(120);
|
||||||
|
const barsLengthRef = useRef(0);
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
const secs = (seconds % 60).toString().padStart(2, "0");
|
||||||
|
return `${mins}:${secs}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
barsLengthRef.current = bars.length;
|
||||||
|
}, [bars.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.waves.length === 0) {
|
||||||
|
setBars([]);
|
||||||
|
setSubShift(0);
|
||||||
|
prevLengthRef.current = 0;
|
||||||
|
prevWavesRef.current = [];
|
||||||
|
lastAppendAtRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.waves.length < prevLengthRef.current) {
|
||||||
|
const resetBars = props.waves.slice(-VISIBLE_BARS).map((value) => ({
|
||||||
|
id: idRef.current++,
|
||||||
|
value,
|
||||||
|
entered: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setBars(resetBars);
|
||||||
|
setSubShift(0);
|
||||||
|
prevLengthRef.current = props.waves.length;
|
||||||
|
prevWavesRef.current = props.waves;
|
||||||
|
lastAppendAtRef.current = performance.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevWaves = prevWavesRef.current;
|
||||||
|
let appended: number[] = [];
|
||||||
|
|
||||||
|
// Обычный режим: длина выросла
|
||||||
|
if (props.waves.length > prevLengthRef.current) {
|
||||||
|
appended = props.waves.slice(prevLengthRef.current);
|
||||||
|
} else if (props.waves.length === prevLengthRef.current && props.waves.length > 0) {
|
||||||
|
// Rolling buffer: длина та же, но данные сдвигаются
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (prevWaves.length !== props.waves.length) {
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < props.waves.length; i++) {
|
||||||
|
if (props.waves[i] !== prevWaves[i]) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
appended = [props.waves[props.waves.length - 1]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appended.length > 0) {
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
if (lastAppendAtRef.current != null) {
|
||||||
|
const dt = now - lastAppendAtRef.current;
|
||||||
|
const perBar = dt / appended.length;
|
||||||
|
appendIntervalRef.current = appendIntervalRef.current * 0.7 + perBar * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAppendAtRef.current = now;
|
||||||
|
setSubShift(0);
|
||||||
|
|
||||||
|
const newIds: number[] = [];
|
||||||
|
|
||||||
|
setBars((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
|
||||||
|
appended.forEach((value) => {
|
||||||
|
const id = idRef.current++;
|
||||||
|
newIds.push(id);
|
||||||
|
|
||||||
|
next.push({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
entered: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return next.slice(-VISIBLE_BARS);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (enterFrameRef.current) {
|
||||||
|
cancelAnimationFrame(enterFrameRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
enterFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
setBars((prev) => {
|
||||||
|
const ids = new Set(newIds);
|
||||||
|
return prev.map((bar) => (ids.has(bar.id) ? { ...bar, entered: true } : bar));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prevLengthRef.current = props.waves.length;
|
||||||
|
prevWavesRef.current = props.waves;
|
||||||
|
}, [props.waves]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => {
|
||||||
|
const startedAt = lastAppendAtRef.current;
|
||||||
|
|
||||||
|
if (startedAt != null) {
|
||||||
|
const elapsed = performance.now() - startedAt;
|
||||||
|
const interval = Math.max(16, appendIntervalRef.current);
|
||||||
|
const progress = Math.min(1, elapsed / interval);
|
||||||
|
|
||||||
|
const smoothShift = barsLengthRef.current >= VISIBLE_BARS ? -progress * STEP_PX : 0;
|
||||||
|
setSubShift(smoothShift);
|
||||||
|
} else {
|
||||||
|
setSubShift(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (scrollFrameRef.current) {
|
||||||
|
cancelAnimationFrame(scrollFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (enterFrameRef.current) {
|
||||||
|
cancelAnimationFrame(enterFrameRef.current);
|
||||||
|
}
|
||||||
|
if (scrollFrameRef.current) {
|
||||||
|
cancelAnimationFrame(scrollFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const waveformWidth = VISIBLE_BARS * BAR_WIDTH + (VISIBLE_BARS - 1) * BAR_GAP;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction="row"
|
||||||
|
h={COMPONENT_HEIGHT}
|
||||||
|
mih={COMPONENT_HEIGHT}
|
||||||
|
mah={COMPONENT_HEIGHT}
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap="xs"
|
||||||
|
px={6}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed" w={36}>
|
||||||
|
{formatDuration(props.duration)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
w={waveformWidth}
|
||||||
|
h={COMPONENT_HEIGHT}
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
h="100%"
|
||||||
|
align="center"
|
||||||
|
gap={BAR_GAP}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ transform: `translateX(${subShift}px)` }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: VISIBLE_BARS }).map((_, index) => {
|
||||||
|
const bar = bars[index];
|
||||||
|
|
||||||
|
if (!bar) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={`empty-${index}`}
|
||||||
|
w={BAR_WIDTH}
|
||||||
|
h={MIN_BAR_HEIGHT}
|
||||||
|
style={{
|
||||||
|
flex: `0 0 ${BAR_WIDTH}px`,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: theme.colors.gray[3],
|
||||||
|
opacity: 0.22,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = Math.max(0, Math.min(1, bar.value));
|
||||||
|
const height = Math.max(
|
||||||
|
MIN_BAR_HEIGHT,
|
||||||
|
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
|
||||||
|
);
|
||||||
|
const isLast = index === bars.length - 1;
|
||||||
|
const isNearTail = index >= bars.length - 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={bar.id}
|
||||||
|
w={BAR_WIDTH}
|
||||||
|
h={height}
|
||||||
|
style={{
|
||||||
|
flex: `0 0 ${BAR_WIDTH}px`,
|
||||||
|
alignSelf: "center",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: isLast
|
||||||
|
? `linear-gradient(180deg, ${theme.colors.blue[3]} 0%, ${theme.colors.blue[5]} 100%)`
|
||||||
|
: `linear-gradient(180deg, ${theme.colors.blue[4]} 0%, ${theme.colors.blue[6]} 100%)`,
|
||||||
|
boxShadow: isLast
|
||||||
|
? `0 0 10px ${theme.colors.blue[4]}55`
|
||||||
|
: isNearTail
|
||||||
|
? `0 0 6px ${theme.colors.blue[4]}22`
|
||||||
|
: "none",
|
||||||
|
transform: bar.entered ? "scaleY(1)" : "scaleY(0.18)",
|
||||||
|
transformOrigin: "center center",
|
||||||
|
transition: [
|
||||||
|
"height 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
|
"transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
|
"opacity 220ms ease",
|
||||||
|
"box-shadow 220ms ease",
|
||||||
|
].join(", "),
|
||||||
|
willChange: "height, transform, opacity",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
|||||||
const publicKey = usePublicKey();
|
const publicKey = usePublicKey();
|
||||||
const privatePlain = usePrivatePlain();
|
const privatePlain = usePrivatePlain();
|
||||||
const {updateAttachmentInDialogCache} = useDialogsCache();
|
const {updateAttachmentInDialogCache} = useDialogsCache();
|
||||||
const {info} = useConsoleLogger('useAttachment');
|
const { info, error } = useConsoleLogger('useAttachment');
|
||||||
const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
|
const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
|
||||||
const {getDownloadsPath} = useCore();
|
const {getDownloadsPath} = useCore();
|
||||||
const {hasGroup} = useGroups();
|
const {hasGroup} = useGroups();
|
||||||
@@ -137,8 +137,7 @@ export function useAttachment(attachment: Attachment, parentMessage: MessageProp
|
|||||||
downloadedBlob = await downloadFile(attachment.id,
|
downloadedBlob = await downloadFile(attachment.id,
|
||||||
downloadTag, attachment.transport.transport_server);
|
downloadTag, attachment.transport.transport_server);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.info(e);
|
error("Error downloading attachment: " + attachment.id);
|
||||||
info("Error downloading attachment: " + attachment.id);
|
|
||||||
setDownloadStatus(DownloadStatus.ERROR);
|
setDownloadStatus(DownloadStatus.ERROR);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function usePrepareAttachment() {
|
|||||||
const {uploadFile} = useTransport();
|
const {uploadFile} = useTransport();
|
||||||
const {updateDialog} = useDialogsList();
|
const {updateDialog} = useDialogsList();
|
||||||
const {runQuery, getQuery} = useDatabase();
|
const {runQuery, getQuery} = useDatabase();
|
||||||
const {info} = useConsoleLogger('usePrepareAttachment');
|
const {info, error} = useConsoleLogger('usePrepareAttachment');
|
||||||
const {getDialogCache} = useDialogsCache();
|
const {getDialogCache} = useDialogsCache();
|
||||||
const context = useContext(DialogContext);
|
const context = useContext(DialogContext);
|
||||||
const transportServer = useTransportServer();
|
const transportServer = useTransportServer();
|
||||||
@@ -197,11 +197,19 @@ export function usePrepareAttachment() {
|
|||||||
const upid = attachment.id;
|
const upid = attachment.id;
|
||||||
info(`Uploading attachment with upid: ${upid}`);
|
info(`Uploading attachment with upid: ${upid}`);
|
||||||
info(`Attachment content length: ${content.length}`);
|
info(`Attachment content length: ${content.length}`);
|
||||||
let tag = await uploadFile(upid, content);
|
let tag = await uploadFile(upid, content).catch(() => {
|
||||||
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
|
error(`Network error while uploading attachment ${upid}`);
|
||||||
if(intervalsRef.current != null){
|
});
|
||||||
clearInterval(intervalsRef.current);
|
if(!tag){
|
||||||
|
/**
|
||||||
|
* При ошибке загрузки по сети
|
||||||
|
*/
|
||||||
|
stopUpdateTimeInUpAttachment();
|
||||||
|
console.info("stop upd")
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
|
||||||
|
stopUpdateTimeInUpAttachment();
|
||||||
const preparedAttachment : Attachment = {
|
const preparedAttachment : Attachment = {
|
||||||
...attachment,
|
...attachment,
|
||||||
transport: {
|
transport: {
|
||||||
@@ -220,6 +228,12 @@ export function usePrepareAttachment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stopUpdateTimeInUpAttachment = () => {
|
||||||
|
if(intervalsRef.current != null){
|
||||||
|
clearInterval(intervalsRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prepareAttachmentsToSend
|
prepareAttachmentsToSend
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,12 +239,6 @@ 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;
|
return;
|
||||||
}
|
}
|
||||||
callSessionIdRef.current = packet.getCallId();
|
callSessionIdRef.current = packet.getCallId();
|
||||||
|
|||||||
@@ -238,6 +238,10 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
* Если это групповое сообщение, то получаем ключ группы
|
* Если это групповое сообщение, то получаем ключ группы
|
||||||
*/
|
*/
|
||||||
decryptKey = await getGroupKey(props.dialog);
|
decryptKey = await getGroupKey(props.dialog);
|
||||||
|
/**
|
||||||
|
* Приводим к HEX так как этого требует формат расшифровки вложений в приложении
|
||||||
|
*/
|
||||||
|
decryptKey = Buffer.from(decryptKey).toString('hex');
|
||||||
}
|
}
|
||||||
if(!message.from_me && !hasGroup(props.dialog)){
|
if(!message.from_me && !hasGroup(props.dialog)){
|
||||||
/**
|
/**
|
||||||
@@ -569,7 +573,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: 0,
|
readed: 0,
|
||||||
chacha_key: groupKey,
|
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||||
from_me: 1,
|
from_me: 1,
|
||||||
plain_message: decryptedContent,
|
plain_message: decryptedContent,
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
@@ -723,7 +727,7 @@ export function DialogProvider(props: DialogProviderProps) {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: groupKey,
|
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: decryptedContent,
|
plain_message: decryptedContent,
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
|
|||||||
@@ -146,8 +146,12 @@ export function useDialog() : {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.info("Sending key for message ", key.toString('hex'));
|
console.info("Sending key for message ", key.toString('hex'));
|
||||||
|
console.info(attachemnts);
|
||||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), 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]);
|
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
||||||
updateDialog(dialog);
|
updateDialog(dialog);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function useDialogFiber() {
|
|||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
readed: idle ? 0 : 1,
|
readed: idle ? 0 : 1,
|
||||||
chacha_key: groupKey,
|
chacha_key: Buffer.from(groupKey).toString('hex'),
|
||||||
from_me: fromPublicKey == publicKey ? 1 : 0,
|
from_me: fromPublicKey == publicKey ? 1 : 0,
|
||||||
plain_message: decryptedContent,
|
plain_message: decryptedContent,
|
||||||
delivered: DeliveredMessageState.DELIVERED,
|
delivered: DeliveredMessageState.DELIVERED,
|
||||||
|
|||||||
300
app/providers/PlayerProvider/PlayerProvider.tsx
Normal file
300
app/providers/PlayerProvider/PlayerProvider.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { createContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface PlayerContextValue {
|
||||||
|
playAudio: (
|
||||||
|
artist: string,
|
||||||
|
title: string,
|
||||||
|
audio: string | Blob | File,
|
||||||
|
messageId?: string | null
|
||||||
|
) => void;
|
||||||
|
playing: boolean;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
setDuration: (duration: number) => void;
|
||||||
|
duration: number;
|
||||||
|
totalDuration: number;
|
||||||
|
currentMessageId: string | null;
|
||||||
|
lastMessageId: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerContext = createContext<PlayerContextValue | null>(null);
|
||||||
|
|
||||||
|
interface PlayerProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerProvider(props: PlayerProviderProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
const objectUrlRef = useRef<string | null>(null);
|
||||||
|
const rafTimeUpdateRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const isLoadingRef = useRef(false);
|
||||||
|
const isSeekingRef = useRef(false);
|
||||||
|
|
||||||
|
const durationRef = useRef(0);
|
||||||
|
const totalDurationRef = useRef(0);
|
||||||
|
|
||||||
|
const isPlayingRef = useRef(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [duration, setDurationState] = useState(0);
|
||||||
|
const [totalDuration, setTotalDuration] = useState(0);
|
||||||
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [currentMessageId, setCurrentMessageId] = useState<string | null>(null);
|
||||||
|
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const commitPlaying = (next: boolean) => {
|
||||||
|
if (isPlayingRef.current === next) return;
|
||||||
|
isPlayingRef.current = next;
|
||||||
|
setIsPlaying(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitDuration = (next: number) => {
|
||||||
|
const safe = Number.isFinite(next) && next >= 0 ? next : 0;
|
||||||
|
if (Math.abs(safe - durationRef.current) < 0.033) return;
|
||||||
|
durationRef.current = safe;
|
||||||
|
setDurationState(safe);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitTotalDuration = (next: number) => {
|
||||||
|
const safe = Number.isFinite(next) && next > 0 ? next : 0;
|
||||||
|
if (Math.abs(safe - totalDurationRef.current) < 0.05) return;
|
||||||
|
totalDurationRef.current = safe;
|
||||||
|
setTotalDuration(safe);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeMediaError = (err: MediaError | null) => {
|
||||||
|
if (!err) return "Unknown media error";
|
||||||
|
switch (err.code) {
|
||||||
|
case MediaError.MEDIA_ERR_ABORTED:
|
||||||
|
return "Playback aborted";
|
||||||
|
case MediaError.MEDIA_ERR_NETWORK:
|
||||||
|
return "Network error while loading audio";
|
||||||
|
case MediaError.MEDIA_ERR_DECODE:
|
||||||
|
return "Audio decode error";
|
||||||
|
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||||
|
return "Audio source is not supported";
|
||||||
|
default:
|
||||||
|
return `Unknown media error (${err.code})`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const onPlay = () => {
|
||||||
|
if (isLoadingRef.current) return;
|
||||||
|
commitPlaying(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPause = () => {
|
||||||
|
if (isLoadingRef.current) return;
|
||||||
|
commitPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnded = () => {
|
||||||
|
commitPlaying(false);
|
||||||
|
durationRef.current = 0;
|
||||||
|
setDurationState(0);
|
||||||
|
setCurrentMessageId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
if (isLoadingRef.current) return;
|
||||||
|
if (isSeekingRef.current) return;
|
||||||
|
if (rafTimeUpdateRef.current != null) return;
|
||||||
|
|
||||||
|
rafTimeUpdateRef.current = requestAnimationFrame(() => {
|
||||||
|
rafTimeUpdateRef.current = null;
|
||||||
|
if (!isLoadingRef.current && !isSeekingRef.current) {
|
||||||
|
commitDuration(audio.currentTime || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => commitTotalDuration(audio.duration);
|
||||||
|
const onDurationChange = () => commitTotalDuration(audio.duration);
|
||||||
|
|
||||||
|
const onSeeked = () => {
|
||||||
|
if (isSeekingRef.current) {
|
||||||
|
isSeekingRef.current = false;
|
||||||
|
if (!isLoadingRef.current) commitDuration(audio.currentTime || 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isLoadingRef.current) return;
|
||||||
|
commitDuration(audio.currentTime || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCanPlay = () => {
|
||||||
|
if (isLoadingRef.current) isLoadingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (_e: Event) => {
|
||||||
|
const message = decodeMediaError(audio.error);
|
||||||
|
setLastError(message);
|
||||||
|
|
||||||
|
console.error("Audio playback error", {
|
||||||
|
message,
|
||||||
|
mediaError: audio.error,
|
||||||
|
currentSrc: audio.currentSrc,
|
||||||
|
readyState: audio.readyState,
|
||||||
|
networkState: audio.networkState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener("play", onPlay);
|
||||||
|
audio.addEventListener("pause", onPause);
|
||||||
|
audio.addEventListener("ended", onEnded);
|
||||||
|
audio.addEventListener("timeupdate", onTimeUpdate);
|
||||||
|
audio.addEventListener("loadedmetadata", onLoadedMetadata);
|
||||||
|
audio.addEventListener("durationchange", onDurationChange);
|
||||||
|
audio.addEventListener("seeked", onSeeked);
|
||||||
|
audio.addEventListener("canplay", onCanPlay);
|
||||||
|
audio.addEventListener("error", onError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("play", onPlay);
|
||||||
|
audio.removeEventListener("pause", onPause);
|
||||||
|
audio.removeEventListener("ended", onEnded);
|
||||||
|
audio.removeEventListener("timeupdate", onTimeUpdate);
|
||||||
|
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||||
|
audio.removeEventListener("durationchange", onDurationChange);
|
||||||
|
audio.removeEventListener("seeked", onSeeked);
|
||||||
|
audio.removeEventListener("canplay", onCanPlay);
|
||||||
|
audio.removeEventListener("error", onError);
|
||||||
|
|
||||||
|
if (rafTimeUpdateRef.current != null) {
|
||||||
|
cancelAnimationFrame(rafTimeUpdateRef.current);
|
||||||
|
rafTimeUpdateRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
objectUrlRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playAudio = (
|
||||||
|
artist: string,
|
||||||
|
title: string,
|
||||||
|
audio: string | Blob | File,
|
||||||
|
messageId?: string | null
|
||||||
|
) => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// чтобы не было warning о неиспользуемых args при строгих правилах
|
||||||
|
void artist;
|
||||||
|
void title;
|
||||||
|
|
||||||
|
setLastError(null);
|
||||||
|
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
objectUrlRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioSrc = typeof audio === "string" ? audio : URL.createObjectURL(audio);
|
||||||
|
if (typeof audio !== "string") {
|
||||||
|
objectUrlRef.current = audioSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingRef.current = true;
|
||||||
|
isSeekingRef.current = false;
|
||||||
|
|
||||||
|
el.src = audioSrc;
|
||||||
|
durationRef.current = 0;
|
||||||
|
|
||||||
|
const msgId = messageId ?? null;
|
||||||
|
setCurrentMessageId(msgId);
|
||||||
|
if (msgId) setLastMessageId(msgId);
|
||||||
|
|
||||||
|
isPlayingRef.current = true;
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
const prevDuration = durationRef.current;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (durationRef.current === prevDuration) {
|
||||||
|
setDurationState(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void el.play().catch((err) => {
|
||||||
|
isLoadingRef.current = false;
|
||||||
|
commitPlaying(false);
|
||||||
|
setLastError(err instanceof Error ? err.message : "play() failed");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
commitPlaying(true);
|
||||||
|
|
||||||
|
void el.play().catch((err) => {
|
||||||
|
commitPlaying(false);
|
||||||
|
setLastError(err instanceof Error ? err.message : "resume() failed");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
isLoadingRef.current = true;
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
isLoadingRef.current = false;
|
||||||
|
|
||||||
|
durationRef.current = 0;
|
||||||
|
setDurationState(0);
|
||||||
|
commitPlaying(false);
|
||||||
|
setCurrentMessageId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDuration = (sec: number) => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
isSeekingRef.current = true;
|
||||||
|
el.currentTime = Math.max(0, sec);
|
||||||
|
commitDuration(el.currentTime || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerContext.Provider
|
||||||
|
value={{
|
||||||
|
playAudio,
|
||||||
|
playing: isPlaying,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
setDuration,
|
||||||
|
duration,
|
||||||
|
totalDuration,
|
||||||
|
currentMessageId,
|
||||||
|
lastMessageId,
|
||||||
|
lastError,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
<audio ref={audioRef} />
|
||||||
|
</PlayerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/providers/PlayerProvider/usePlayerContext.ts
Normal file
10
app/providers/PlayerProvider/usePlayerContext.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { PlayerContext, PlayerContextValue } from "./PlayerProvider";
|
||||||
|
|
||||||
|
export function usePlayerContext() : PlayerContextValue {
|
||||||
|
const context = useContext(PlayerContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAudioPlayer must be used within a PlayerProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ export enum AttachmentType {
|
|||||||
MESSAGES = 1,
|
MESSAGES = 1,
|
||||||
FILE = 2,
|
FILE = 2,
|
||||||
AVATAR = 3,
|
AVATAR = 3,
|
||||||
CALL = 4
|
CALL = 4,
|
||||||
|
VOICE = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
|
|||||||
return "$a=Avatar";
|
return "$a=Avatar";
|
||||||
case AttachmentType.CALL:
|
case AttachmentType.CALL:
|
||||||
return "$a=Call";
|
return "$a=Call";
|
||||||
|
case AttachmentType.VOICE:
|
||||||
|
return "$a=Voice message";
|
||||||
default:
|
default:
|
||||||
return "[Unsupported attachment]";
|
return "[Unsupported attachment]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export const APP_VERSION = "1.1.9";
|
export const APP_VERSION = "1.2.2";
|
||||||
export const CORE_MIN_REQUIRED_VERSION = "1.5.5";
|
export const CORE_MIN_REQUIRED_VERSION = "1.5.5";
|
||||||
|
|
||||||
export const RELEASE_NOTICE = `
|
export const RELEASE_NOTICE = `
|
||||||
**Обновление v1.1.9** :emoji_1f631:
|
**Обновление v1.2.2** :emoji_1f631:
|
||||||
- Новый протокол для обмена звонками с использованием callId и joinToken для улучшенной безопасности и надежности.
|
- Поддержка записи и прослушивания голосовых сообщений
|
||||||
- Исправление ошибок и улучшение производительности при обработке звонков.
|
|
||||||
`;
|
`;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ColorSwatch, Text, useComputedColorScheme } from "@mantine/core";
|
import { Button, ColorSwatch, Flex, Text, useComputedColorScheme } from "@mantine/core";
|
||||||
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
|
import { SettingsInput } from "@/app/components/SettingsInput/SettingsInput";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Breadcrumbs } from "@/app/components/Breadcrumbs/Breadcrumbs";
|
import { Breadcrumbs } from "@/app/components/Breadcrumbs/Breadcrumbs";
|
||||||
@@ -17,6 +17,7 @@ import { SettingsIcon } from "@/app/components/SettingsIcon/SettingsIcon";
|
|||||||
import { IconBrush, IconHomeCog, IconLogout, IconRefresh } from "@tabler/icons-react";
|
import { IconBrush, IconHomeCog, IconLogout, IconRefresh } from "@tabler/icons-react";
|
||||||
import { useLogout } from "@/app/providers/AccountProvider/useLogout";
|
import { useLogout } from "@/app/providers/AccountProvider/useLogout";
|
||||||
import { RosettaPower } from "@/app/components/RosettaPower/RosettaPower";
|
import { RosettaPower } from "@/app/components/RosettaPower/RosettaPower";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
|
||||||
export function MyProfile() {
|
export function MyProfile() {
|
||||||
const publicKey = usePublicKey();
|
const publicKey = usePublicKey();
|
||||||
@@ -28,8 +29,34 @@ export function MyProfile() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const send = useSender();
|
const send = useSender();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
|
const [usernameError, setUsernameError] = useState(false);
|
||||||
|
|
||||||
|
const openProfileModal = (text : string) => {
|
||||||
|
modals.open({
|
||||||
|
centered: true,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Text size="sm">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
<Flex align={'center'} justify={'flex-end'}>
|
||||||
|
<Button style={{
|
||||||
|
outline: 'none'
|
||||||
|
}} color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
withCloseButton: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const saveProfileData = () => {
|
const saveProfileData = () => {
|
||||||
|
if(usernameError) {
|
||||||
|
openProfileModal("You enter invalid username. Username must be a latin chars in lowercase.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let packet = new PacketUserInfo();
|
let packet = new PacketUserInfo();
|
||||||
packet.setUsername(username);
|
packet.setUsername(username);
|
||||||
packet.setTitle(title);
|
packet.setTitle(title);
|
||||||
@@ -70,10 +97,13 @@ export function MyProfile() {
|
|||||||
<SettingsInput.Default
|
<SettingsInput.Default
|
||||||
hit="Username"
|
hit="Username"
|
||||||
value={username}
|
value={username}
|
||||||
|
onErrorStateChange={(error) => setUsernameError(error)}
|
||||||
placeholder="ex. freddie871"
|
placeholder="ex. freddie871"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
regexp={new RegExp(/^([a-z][a-z0-9_]{4,15})?$/)}
|
||||||
></SettingsInput.Default>
|
></SettingsInput.Default>
|
||||||
</SettingsInput.Group>
|
</SettingsInput.Group>
|
||||||
|
{usernameError && <Text c={'red'} fz={10} pl={'xs'} mt={3}>Invalid username.</Text>}
|
||||||
<SettingsInput.Copy mt={'sm'} hit="Public Key" value={
|
<SettingsInput.Copy mt={'sm'} hit="Public Key" value={
|
||||||
publicKey
|
publicKey
|
||||||
} placeholder="Public"></SettingsInput.Copy>
|
} placeholder="Public"></SettingsInput.Copy>
|
||||||
|
|||||||
Reference in New Issue
Block a user