Шифрование, GIF, хранилище, админка
This commit is contained in:
@@ -38,7 +38,7 @@ import Avatar from './Avatar';
|
||||
import { useThemeStore } from '../stores/themeStore';
|
||||
|
||||
export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCall?: (targetUser: UserBasic, type: 'voice' | 'video') => void; onStartGroupCall?: (chatId: string, chatName: string, type: 'voice' | 'video') => void }) {
|
||||
const { user } = useAuthStore();
|
||||
const { user, config } = useAuthStore();
|
||||
const { t, lang } = useLang();
|
||||
const { chatTheme } = useThemeStore();
|
||||
const {
|
||||
@@ -586,7 +586,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
{showSearch ? <X size={18} /> : <Search size={18} />}
|
||||
</button>
|
||||
|
||||
{!isFavorites && (
|
||||
{!isFavorites && config?.enableCalls && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -768,7 +768,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
{/* Закреплённое сообщение */}
|
||||
{/* Active group call banner */}
|
||||
{chat?.type === 'group' && activeGroupCallParticipants.length > 0 && (
|
||||
{chat?.type === 'group' && config?.enableCalls && activeGroupCallParticipants.length > 0 && (
|
||||
<button
|
||||
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"
|
||||
|
||||
@@ -8,14 +8,11 @@ import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
interface KlipyGif {
|
||||
id: string;
|
||||
images: {
|
||||
original: { url: string };
|
||||
fixed_height_small: { url: string };
|
||||
};
|
||||
file: {
|
||||
sd?: { gif?: { url: string }; webp?: { url: string } };
|
||||
hd?: { gif?: { url: string }; webp?: { url: string } };
|
||||
};
|
||||
files?: any;
|
||||
file?: any;
|
||||
media_formats?: any;
|
||||
media?: any;
|
||||
images?: any;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -25,14 +22,18 @@ interface EmojiPickerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getKlipyKey = () => import.meta.env.VITE_KLIPY_API_KEY || '';
|
||||
const getCustomerId = () => {
|
||||
const user = useAuthStore.getState().user;
|
||||
return user?.id || 'anonymous';
|
||||
const getKlipyKey = (config: any) => {
|
||||
return config?.klipyApiKey || import.meta.env.VITE_KLIPY_API_KEY || '';
|
||||
};
|
||||
const getCustomerId = (config: any, user: any) => {
|
||||
return config?.klipyCustomerId || user?.id || 'anonymous';
|
||||
};
|
||||
|
||||
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
|
||||
const { lang, t } = useLang();
|
||||
const { config, user } = useAuthStore();
|
||||
const klipyApiKey = getKlipyKey(config);
|
||||
const klipyCustomerId = getCustomerId(config, user);
|
||||
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
|
||||
const [gifQuery, setGifQuery] = useState('');
|
||||
const [gifs, setGifs] = useState<KlipyGif[]>([]);
|
||||
@@ -40,10 +41,12 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
|
||||
const gifSearchRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const initialFetchDone = useRef(false);
|
||||
|
||||
// Helper to safely extract GIF array from various possible Klipy API responses
|
||||
const extractGifs = (d: any): KlipyGif[] => {
|
||||
if (!d) return [];
|
||||
if (d.data && Array.isArray(d.data.data)) return d.data.data;
|
||||
if (Array.isArray(d)) return d;
|
||||
if (Array.isArray(d.data)) return d.data;
|
||||
if (Array.isArray(d.result)) return d.result;
|
||||
@@ -54,10 +57,17 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
|
||||
// Load trending GIFs (Klipy)
|
||||
useEffect(() => {
|
||||
if (tab === 'gif' && getKlipyKey() && trendingGifs.length === 0) {
|
||||
if (tab === 'gif' && klipyApiKey && !initialFetchDone.current) {
|
||||
initialFetchDone.current = true;
|
||||
setGifLoading(true);
|
||||
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/trending?customer_id=${getCustomerId()}&per_page=30`)
|
||||
.then(r => r.json())
|
||||
fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/trending?page=1&per_page=30&customer_id=${klipyCustomerId}`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
throw new Error(`Klipy error: ${r.status} ${text.substring(0, 100)}`);
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(d => {
|
||||
setTrendingGifs(extractGifs(d));
|
||||
setGifLoading(false);
|
||||
@@ -68,13 +78,19 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
setGifLoading(false);
|
||||
});
|
||||
}
|
||||
}, [tab, trendingGifs.length]);
|
||||
}, [tab, klipyApiKey, klipyCustomerId]);
|
||||
|
||||
const searchGifs = useCallback((q: string) => {
|
||||
if (!getKlipyKey() || !q.trim()) { setGifs([]); return; }
|
||||
if (!klipyApiKey || !q.trim()) { setGifs([]); return; }
|
||||
setGifLoading(true);
|
||||
fetch(`https://api.klipy.com/api/v1/${getKlipyKey()}/gifs/search?customer_id=${getCustomerId()}&q=${encodeURIComponent(q)}&per_page=30`)
|
||||
.then(r => r.json())
|
||||
fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/search?page=1&per_page=30&q=${encodeURIComponent(q)}&customer_id=${klipyCustomerId}`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
throw new Error(`Klipy error: ${r.status} ${text.substring(0, 100)}`);
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(d => {
|
||||
setGifs(extractGifs(d));
|
||||
setGifLoading(false);
|
||||
@@ -84,7 +100,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
setGifs([]);
|
||||
setGifLoading(false);
|
||||
});
|
||||
}, []);
|
||||
}, [klipyApiKey, klipyCustomerId]);
|
||||
|
||||
const handleGifSearch = (q: string) => {
|
||||
setGifQuery(q);
|
||||
@@ -92,9 +108,23 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
debounceRef.current = setTimeout(() => searchGifs(q), 400);
|
||||
};
|
||||
|
||||
const getGifUrl = (gif: any): string => {
|
||||
return gif.files?.hd?.gif?.url || gif.files?.sd?.gif?.url
|
||||
|| gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url
|
||||
|| gif.media_formats?.gif?.url || gif.media?.[0]?.gif?.url
|
||||
|| gif.images?.original?.url || '';
|
||||
};
|
||||
|
||||
const getGifPreview = (gif: any, fullUrl: string): string => {
|
||||
return gif.files?.sd?.webp?.url || gif.files?.sd?.gif?.url
|
||||
|| gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url
|
||||
|| gif.media_formats?.tinygif?.url || gif.media?.[0]?.tinygif?.url
|
||||
|| gif.images?.fixed_height_small?.url || fullUrl;
|
||||
};
|
||||
|
||||
const pickGif = (gif: KlipyGif) => {
|
||||
const url = gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url || '';
|
||||
const preview = gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || url;
|
||||
const url = getGifUrl(gif);
|
||||
const preview = getGifPreview(gif, url);
|
||||
if (onSelectGif && url) {
|
||||
onSelectGif(url, preview);
|
||||
}
|
||||
@@ -132,6 +162,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
className="fixed z-[9991] rounded-2xl shadow-2xl border border-white/10"
|
||||
style={{
|
||||
width: pickerWidth,
|
||||
height: tab === 'gif' ? 435 : undefined,
|
||||
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
|
||||
left: pos ? pos.left : undefined,
|
||||
background: 'rgb(17, 17, 19)',
|
||||
@@ -146,7 +177,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
>
|
||||
EMOJI
|
||||
</button>
|
||||
{(getKlipyKey() || onSelectGif) && (
|
||||
{config?.enableKlipy && (klipyApiKey || onSelectGif) && (
|
||||
<button
|
||||
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
|
||||
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
@@ -176,9 +207,9 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
)}
|
||||
|
||||
{/* GIF tab */}
|
||||
{tab === 'gif' && (
|
||||
{config?.enableKlipy && tab === 'gif' && (
|
||||
<div className="flex flex-col h-[calc(100%-41px)]">
|
||||
{!getKlipyKey() ? (
|
||||
{!klipyApiKey ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
<p className="text-sm text-zinc-400 mb-2">Klipy API Key required</p>
|
||||
<p className="text-xs text-zinc-500 mb-3">{t('openConsoleRun')}</p>
|
||||
@@ -214,7 +245,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
) : displayGifs.length === 0 ? (
|
||||
<p className="text-center text-xs text-zinc-500 py-10">{gifQuery ? t('nothingFound') : ''}</p>
|
||||
) : (
|
||||
<div className="columns-2 gap-1.5">
|
||||
<div className="columns-4 gap-1.5">
|
||||
{displayGifs.map((gif) => (
|
||||
<button
|
||||
key={gif.id}
|
||||
@@ -222,7 +253,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
|
||||
>
|
||||
<img
|
||||
src={gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url || gif.file?.hd?.gif?.url}
|
||||
src={getGifPreview(gif, getGifUrl(gif))}
|
||||
alt={gif.title || 'GIF'}
|
||||
className="w-full h-auto rounded-lg"
|
||||
loading="lazy"
|
||||
|
||||
@@ -498,35 +498,44 @@ function MessageBubble({
|
||||
)}
|
||||
|
||||
{/* Изображения и Видео (Галерея) */}
|
||||
{(hasImage || hasVideo) && (
|
||||
{(hasImage || hasVideo) && (() => {
|
||||
const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video');
|
||||
const isSingleGif = galleryMedia.length === 1 && (
|
||||
galleryMedia[0].filename === 'gif' ||
|
||||
galleryMedia[0].filename === 'gif.gif' ||
|
||||
galleryMedia[0].url?.includes('klipy') ||
|
||||
galleryMedia[0].url?.endsWith('.gif')
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
${(message.content || message.forwardedFrom) ? '-mx-4' : ''}
|
||||
${(message.content || message.forwardedFrom) ? (message.forwardedFrom ? 'mt-2' : '-mt-2.5') : ''}
|
||||
${(message.content || message.forwardedFrom) ? (message.content ? 'mb-2' : '-mb-2.5') : ''}
|
||||
bg-black/20 overflow-hidden
|
||||
${isSingleGif && !(message.content || message.forwardedFrom) ? 'max-w-[260px] rounded-[1.25rem]' : ''}
|
||||
${isSingleGif && (message.content || message.forwardedFrom) ? 'max-h-[260px] mx-auto' : ''}
|
||||
bg-black/20 overflow-hidden relative
|
||||
`}>
|
||||
<div className={`grid gap-[2px] ${media.filter(m => m.type === 'image' || m.type === 'video').length >= 3
|
||||
<div className={`grid gap-[2px] ${galleryMedia.length >= 3
|
||||
? 'grid-cols-3'
|
||||
: media.filter(m => m.type === 'image' || m.type === 'video').length === 2
|
||||
: galleryMedia.length === 2
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-1'
|
||||
}`}>
|
||||
{media
|
||||
.filter((m) => m.type === 'image' || m.type === 'video')
|
||||
.map((m, idx) => (
|
||||
{galleryMedia.map((m, idx) => (
|
||||
m.type === 'image' ? (
|
||||
<img
|
||||
key={m.id}
|
||||
src={m.url}
|
||||
alt=""
|
||||
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 'aspect-square' : 'max-h-[500px]'
|
||||
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'
|
||||
}`}
|
||||
onClick={() => setLightboxData({ index: idx })}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`relative cursor-pointer group/video ${media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 'aspect-square' : ''
|
||||
className={`relative cursor-pointer group/video ${galleryMedia.length > 1 ? 'aspect-square' : ''
|
||||
}`}
|
||||
onClick={() => setLightboxData({ index: idx })}
|
||||
>
|
||||
@@ -535,14 +544,15 @@ function MessageBubble({
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors">
|
||||
<Play size={media.filter(i => i.type === 'image' || i.type === 'video').length > 1 ? 24 : 48} className="text-white opacity-80" />
|
||||
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Голосовое */}
|
||||
{hasVoice && (
|
||||
|
||||
@@ -412,11 +412,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
chatId,
|
||||
content: null,
|
||||
type: 'voice',
|
||||
mediaUrl: result.url,
|
||||
mediaType: 'voice',
|
||||
fileName: result.filename,
|
||||
fileSize: result.size,
|
||||
duration: recordingTimeRef.current,
|
||||
attachments: [{
|
||||
type: 'voice',
|
||||
url: result.url,
|
||||
fileName: result.filename,
|
||||
fileSize: result.size
|
||||
}],
|
||||
replyToId: replyTo?.id || null,
|
||||
});
|
||||
setReplyTo(null);
|
||||
@@ -874,9 +875,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
chatId,
|
||||
content: null,
|
||||
type: 'image',
|
||||
mediaUrl: gifUrl,
|
||||
mediaType: 'image',
|
||||
fileName: 'gif',
|
||||
attachments: [{
|
||||
type: 'image',
|
||||
url: gifUrl,
|
||||
fileName: 'gif.gif',
|
||||
fileSize: 0,
|
||||
}],
|
||||
replyToId: replyTo?.id || null,
|
||||
});
|
||||
setReplyTo(null);
|
||||
|
||||
@@ -14,7 +14,7 @@ interface NewChatModalProps {
|
||||
type Mode = 'personal' | 'group-select' | 'group-name';
|
||||
|
||||
export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { user, config } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
const { addChat, setActiveChat, loadMessages } = useChatStore();
|
||||
const [mode, setMode] = useState<Mode>('personal');
|
||||
@@ -212,7 +212,9 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{t('createGroup')}</p>
|
||||
<p className="text-xs text-zinc-500">{t('upTo200')}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{t('upTo200').replace('200', String(config?.maxGroupMembers || 500))}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -28,7 +28,7 @@ interface UserProfileProps {
|
||||
type MediaTab = 'publications' | 'media' | 'files' | 'links';
|
||||
|
||||
export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) {
|
||||
const { user: authUser } = useAuthStore();
|
||||
const { user: authUser, config } = useAuthStore();
|
||||
const { t, lang } = useLang();
|
||||
const [profile, setProfile] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -576,15 +576,17 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-white shadow-sm">{isMutedLocally ? t('enableSound') : t('disableSound')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStartCall('voice')}
|
||||
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
|
||||
<Phone size={16} className="text-white" />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-white">{t('call')}</span>
|
||||
</button>
|
||||
{config?.enableCalls && (
|
||||
<button
|
||||
onClick={() => handleStartCall('voice')}
|
||||
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
|
||||
<Phone size={16} className="text-white" />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-white">{t('call')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 border border-white/5 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user