Reorganize web folder structurally
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Debounced value — updates value after the specified delay.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced callback — returns a function that delays execution.
|
||||
*/
|
||||
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
|
||||
callback: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback((...args: Parameters<T>) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => callbackRef.current(...args), delay);
|
||||
}, [delay]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an AbortController that auto-aborts on unmount or when reset is called.
|
||||
*/
|
||||
export function useAbortController() {
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const getSignal = useCallback(() => {
|
||||
if (controllerRef.current) controllerRef.current.abort();
|
||||
controllerRef.current = new AbortController();
|
||||
return controllerRef.current.signal;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return getSignal;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Notification sound using Web Audio API — generates a pleasant chime
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext | null {
|
||||
if (typeof window !== 'undefined' && navigator && 'userActivation' in navigator) {
|
||||
if (!(navigator as any).userActivation.hasBeenActive) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioContext) {
|
||||
try {
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
audioContext = new AudioCtx();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
export function playNotificationSound() {
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Soft, warm notification — lower frequencies, triangle waves, gentle volume
|
||||
// First note — warm mellow tone
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.type = 'triangle';
|
||||
osc1.frequency.setValueAtTime(523.25, now); // C5
|
||||
gain1.gain.setValueAtTime(0.08, now);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(ctx.destination);
|
||||
osc1.start(now);
|
||||
osc1.stop(now + 0.3);
|
||||
|
||||
// Second note — gentle higher tone
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.type = 'triangle';
|
||||
osc2.frequency.setValueAtTime(659.25, now + 0.08); // E5
|
||||
gain2.gain.setValueAtTime(0, now);
|
||||
gain2.gain.setValueAtTime(0.06, now + 0.08);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(ctx.destination);
|
||||
osc2.start(now + 0.08);
|
||||
osc2.stop(now + 0.35);
|
||||
} catch (e) {
|
||||
// Audio context not supported — silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// Muted chats stored in localStorage
|
||||
const MUTED_KEY = 'knot_muted_chats';
|
||||
|
||||
export function getMutedChats(): Set<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(MUTED_KEY);
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMuteChat(chatId: string): boolean {
|
||||
const muted = getMutedChats();
|
||||
if (muted.has(chatId)) {
|
||||
muted.delete(chatId);
|
||||
} else {
|
||||
muted.add(chatId);
|
||||
}
|
||||
localStorage.setItem(MUTED_KEY, JSON.stringify([...muted]));
|
||||
return muted.has(chatId);
|
||||
}
|
||||
|
||||
export function isChatMuted(chatId: string): boolean {
|
||||
return getMutedChats().has(chatId);
|
||||
}
|
||||
|
||||
// Call ringtone
|
||||
let callAudio: HTMLAudioElement | null = null;
|
||||
|
||||
export function playCallRingtone() {
|
||||
try {
|
||||
if (callAudio) {
|
||||
callAudio.pause();
|
||||
callAudio.currentTime = 0;
|
||||
}
|
||||
callAudio = new Audio('/sounds/call_sound.mp3');
|
||||
callAudio.loop = true;
|
||||
callAudio.volume = 0.5;
|
||||
callAudio.play().catch(() => {});
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
export function stopCallRingtone() {
|
||||
try {
|
||||
if (callAudio) {
|
||||
callAudio.pause();
|
||||
callAudio.currentTime = 0;
|
||||
callAudio = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// "Абонент недоступен" sound
|
||||
export function playUnavailableSound(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const audio = new Audio('/sounds/abonent_nedostupen.mp3');
|
||||
audio.volume = 0.7;
|
||||
audio.onended = () => resolve();
|
||||
audio.onerror = () => resolve();
|
||||
audio.play().catch(() => resolve());
|
||||
} catch (e) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function formatTime(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return lang === 'ru' ? 'Сегодня' : 'Today';
|
||||
if (days === 1) return lang === 'ru' ? 'Вчера' : 'Yesterday';
|
||||
if (days < 7) {
|
||||
const weekDaysRu = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
|
||||
const weekDaysEn = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
return (lang === 'ru' ? weekDaysRu : weekDaysEn)[d.getDay()];
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: days > 365 ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLastSeen(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
|
||||
if (minutes < 1) return lang === 'ru' ? 'только что' : 'just now';
|
||||
if (minutes < 60) return lang === 'ru' ? `${minutes} мин. назад` : `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return lang === 'ru' ? `${hours} ч. назад` : `${hours}h ago`;
|
||||
|
||||
const at = lang === 'ru' ? ' в ' : ' at ';
|
||||
return formatDate(date, lang) + at + formatTime(date, lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips markdown syntax (**bold**, *italic*, _italic_, ~strikethrough~, `code`)
|
||||
* and returns plain text for use in previews.
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/\*\*([\s\S]*?)\*\*/g, '$1')
|
||||
.replace(/\*([\s\S]*?)\*/g, '$1')
|
||||
.replace(/_([\s\S]*?)_/g, '$1')
|
||||
.replace(/~([\s\S]*?)~/g, '$1')
|
||||
.replace(/`([\s\S]*?)`/g, '$1');
|
||||
}
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
export function generateAvatarColor(name: string): string {
|
||||
const colors = [
|
||||
'from-violet-500 to-purple-600',
|
||||
'from-blue-500 to-indigo-600',
|
||||
'from-emerald-500 to-teal-600',
|
||||
'from-rose-500 to-pink-600',
|
||||
'from-amber-500 to-orange-600',
|
||||
'from-cyan-500 to-blue-600',
|
||||
'from-fuchsia-500 to-purple-600',
|
||||
'from-lime-500 to-green-600',
|
||||
];
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
// Waveform cache so we don't decode the same audio twice
|
||||
const waveformCache = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Decodes an audio file from a URL and extracts normalized waveform peak values.
|
||||
* Returns an array of `bars` values in [0, 1].
|
||||
*/
|
||||
export async function extractWaveform(url: string, bars: number = 28): Promise<number[]> {
|
||||
const cached = waveformCache.get(url);
|
||||
if (cached) return cached;
|
||||
|
||||
let audioCtx: any;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const OfflineCtx = window.OfflineAudioContext || (window as any).webkitOfflineAudioContext;
|
||||
if (OfflineCtx) {
|
||||
audioCtx = new OfflineCtx(1, 1, 44100);
|
||||
} else {
|
||||
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
|
||||
// We only need the buffer, so decode it
|
||||
const audioBuffer = await audioCtx!.decodeAudioData(arrayBuffer);
|
||||
|
||||
// If it was a regular AudioContext, close it.
|
||||
if (audioCtx.close) {
|
||||
await audioCtx.close();
|
||||
}
|
||||
audioCtx = undefined;
|
||||
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const samplesPerBar = Math.floor(channelData.length / bars);
|
||||
const peaks: number[] = [];
|
||||
|
||||
for (let i = 0; i < bars; i++) {
|
||||
let peak = 0;
|
||||
const start = i * samplesPerBar;
|
||||
// Sample a subset for performance
|
||||
const step = Math.max(1, Math.floor(samplesPerBar / 200));
|
||||
for (let j = 0; j < samplesPerBar; j += step) {
|
||||
const abs = Math.abs(channelData[start + j] || 0);
|
||||
if (abs > peak) peak = abs;
|
||||
}
|
||||
peaks.push(peak);
|
||||
}
|
||||
|
||||
// Normalize to [0, 1]
|
||||
const max = Math.max(...peaks, 0.01);
|
||||
const normalized = peaks.map(p => p / max);
|
||||
waveformCache.set(url, normalized);
|
||||
return normalized;
|
||||
} catch {
|
||||
// Close leaked AudioContext if any
|
||||
if (audioCtx && audioCtx.close) audioCtx.close().catch(() => {});
|
||||
// On error, return uniform bars
|
||||
return Array(bars).fill(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
export function getMediaUrl(url: string | null | undefined): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:')) return url;
|
||||
|
||||
// Use VITE_API_URL if defined, otherwise let it be a relative path which the browser
|
||||
// will resolve against the current origin (port).
|
||||
const baseUrl = import.meta.env.VITE_API_URL || '';
|
||||
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
Reference in New Issue
Block a user