Заготовка опросов

This commit is contained in:
Халимов Рустам
2026-04-07 01:01:29 +03:00
parent 02a85fc587
commit c45f4db61c
23 changed files with 824 additions and 249 deletions

View File

@@ -244,6 +244,7 @@ const translations = {
telegramImport: 'Telegram Import',
serverDesc: 'Server Description',
successSave: 'Settings saved',
successClean: 'Cleanup completed',
errorSave: 'Save failed',
errorInvalidLogin: 'Invalid credentials',
errorDelete: 'Delete failed',
@@ -414,6 +415,7 @@ const translations = {
telegramImport: 'Импорт Telegram',
serverDesc: 'Описание сервера',
successSave: 'Сохранено',
successClean: 'Очищено',
errorSave: 'Ошибка сохранения',
errorInvalidLogin: 'Неверный логин или пароль',
errorDelete: 'Ошибка удаления',
@@ -745,7 +747,7 @@ export default function AdminPage() {
const handleRunCleanup = async () => {
try {
await httpClient.request('/admin/clean/run', { method: 'POST' });
showToast(t.successSave, 'success');
showToast(t.successClean, 'success');
setCleanStats(null);
fetchDashboard();
} catch { showToast(t.errorSave, 'error'); }

View File

@@ -7,7 +7,7 @@ interface ChatState {
chats: Chat[];
activeChat: string | null;
messages: Record<string, Message[]>;
pinnedMessages: Record<string, Message>;
pinnedMessages: Record<string, Message[]>;
typingUsers: TypingUser[];
replyTo: Message | null;
editingMessage: Message | null;
@@ -42,7 +42,7 @@ interface ChatState {
removeChat: (chatId: string) => void;
clearMessages: (chatId: string) => void;
setPinnedMessage: (chatId: string, message: Message) => void;
removePinnedMessage: (chatId: string, messageId: string, newPinned: Message | null) => void;
removePinnedMessage: (chatId: string, messageId: string, newPinned?: Message[] | null) => void;
clearStore: () => void;
}
@@ -99,10 +99,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
} catch { }
}
// Extract pinned messages from chats
const pinnedMessages: Record<string, Message> = {};
const pinnedMessages: Record<string, Message[]> = {};
for (const chat of chats) {
if (chat.pinnedMessages && chat.pinnedMessages.length > 0) {
pinnedMessages[chat.id] = chat.pinnedMessages[0].message;
pinnedMessages[chat.id] = chat.pinnedMessages.map((pm: any) => pm.message);
}
}
set({ chats, pinnedMessages, isLoadingChats: false });
@@ -519,18 +519,27 @@ export const useChatStore = create<ChatState>((set, get) => ({
},
setPinnedMessage: (chatId, message) => {
set((state) => ({
pinnedMessages: { ...state.pinnedMessages, [chatId]: message },
}));
set((state) => {
const existing = state.pinnedMessages[chatId] || [];
if (existing.some(m => m.id === message.id)) return state;
return {
pinnedMessages: {
...state.pinnedMessages,
[chatId]: [...existing, message]
},
};
});
},
removePinnedMessage: (chatId, _messageId, newPinned) => {
removePinnedMessage: (chatId, messageId, newPinned?) => {
set((state) => {
const updated = { ...state.pinnedMessages };
if (newPinned) {
updated[chatId] = newPinned;
} else {
delete updated[chatId];
const filtered = (updated[chatId] || []).filter(m => m.id !== messageId);
if (filtered.length === 0) delete updated[chatId];
else updated[chatId] = filtered;
}
return { pinnedMessages: updated };
});

View File

@@ -206,8 +206,8 @@ export default function ChatPage() {
setPinnedMessage(data.chatId, data.message);
});
socket.on('message_unpinned', (data: { chatId: string; messageId: string; newPinnedMessage: Message | null }) => {
removePinnedMessage(data.chatId, data.messageId, data.newPinnedMessage);
socket.on('message_unpinned', (data: { chatId: string; messageId: string }) => {
removePinnedMessage(data.chatId, data.messageId);
});
socket.on('call_incoming', async (data: CallInfo) => {

View File

@@ -77,7 +77,58 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
cleanup?.();
const tryScroll = () => {
const el = document.getElementById(`msg-${msgId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
return true;
}
return false;
};
if (tryScroll()) return;
if (!activeChat) return;
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...');
const chatStore = useChatStore.getState();
let found = false;
const targetDate = targetCreatedAt ? new Date(targetCreatedAt).getTime() : 0;
for (let i = 0; i < 100; i++) {
const chatMessages = chatStore.messages[activeChat] || [];
const oldestLoaded = chatMessages.length > 0 ? new Date(chatMessages[0].createdAt).getTime() : Date.now();
if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) {
if (tryScroll()) { found = true; break; }
}
if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break;
await chatStore.loadMessages(activeChat, false, true);
await new Promise(resolve => setTimeout(resolve, 150));
if (tryScroll()) {
found = true;
break;
}
if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) {
if (i > 10) break;
}
}
if (!found) {
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
}
};
const searchInputRef = useRef<HTMLInputElement>(null);
const topMenuRef = useRef<HTMLDivElement>(null);
const deleteMenuRef = useRef<HTMLDivElement>(null);
@@ -87,7 +138,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const allChatMessages = activeChat ? messages[activeChat] || [] : [];
// Filter out deleted messages to prevent layout shifts
const chatMessages = allChatMessages.filter(m => !m.isDeleted);
const pinnedMsg = activeChat ? pinnedMessages[activeChat] : null;
const chatPinnedMessages = activeChat ? pinnedMessages[activeChat] || [] : [];
const [pinnedIndex, setPinnedIndex] = useState(0);
const [importStatus, setImportStatus] = useState<{ processed: number, total: number, status: string } | null>(null);
const isAtBottomRef = useRef(false);
@@ -213,8 +265,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const scrollToBottom = useCallback((smooth = true) => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
} else if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
} else if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
}, []);
@@ -225,7 +277,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// 1. СОХРАНЕНИЕ ПОЗИЦИИ (ЯКОРНОЕ ПО MESSAGE ID)
const saveScrollPosition = useCallback((targetChatId?: string) => {
const container = messagesContainerRef.current;
const container = scrollContainerRef.current;
const chatId = targetChatId || activeChat;
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
@@ -233,7 +285,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Проверка: сообщения в стейте должны быть от целевого чата
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
isAtBottomRef.current = isAtBottomNow;
if (isAtBottomNow) {
@@ -268,7 +320,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// 2. ВОССТАНОВЛЕНИЕ ПОЗИЦИИ
const restoreScrollPosition = useCallback(() => {
const container = messagesContainerRef.current;
const container = scrollContainerRef.current;
if (!container || !activeChat) return false;
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false;
@@ -323,8 +375,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// 3. ОБЗЕРВЕР И УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ
useEffect(() => {
if (isLoadingMessages || !messagesContainerRef.current || !activeChat) return;
const container = messagesContainerRef.current;
if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return;
const container = scrollContainerRef.current;
const observer = new ResizeObserver(() => {
if (isInitializingRef.current) return;
@@ -361,15 +413,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
isScrollingToBottomRef.current = false;
// Сохраняем позицию старого чата
if (prevChatIdRef.current && messagesContainerRef.current && scrollReady) {
if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) {
saveScrollPosition(prevChatIdRef.current);
}
setScrollReady(false);
prevChatIdRef.current = activeChat;
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = 0;
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
const timer = setTimeout(() => {
@@ -381,7 +433,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}, [activeChat, saveScrollPosition, scrollReady]);
const checkScrollPosition = useCallback(() => {
const container = messagesContainerRef.current;
const container = scrollContainerRef.current;
if (!container || isInitializingRef.current) return;
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
setShowScrollDown(!isNearBottom);
@@ -392,10 +444,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (isInitializingRef.current || isScrollingToBottomRef.current) return;
checkScrollPosition();
const container = messagesContainerRef.current;
const container = scrollContainerRef.current;
if (container && activeChat) {
// Synchronous "at bottom" check to prevent ResizeObserver from fighting the user
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
isAtBottomRef.current = isAtBottomNow;
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
@@ -405,12 +457,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const isScrollingUp = st < lastScrollTopRef.current;
lastScrollTopRef.current = st;
// Sticky Date Header Logic - Telegram style: show when scrolling, especially up
if (st > 100) {
// Sticky Date Header Logic - Telegram style
if (st > 100 && (isScrollingUp || st !== lastScrollTopRef.current)) {
setShowStickyDate(true);
if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current);
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 2000);
} else {
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), isScrollingUp ? 1500 : 1000);
} else if (st <= 100) {
setShowStickyDate(false);
}
@@ -451,9 +503,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
localStorage.removeItem(`chat_anchor_${activeChat}`);
isScrollingToBottomRef.current = true;
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}
@@ -514,7 +566,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}
},
{
root: messagesContainerRef.current,
root: scrollContainerRef.current,
threshold: 0.1,
}
);
@@ -522,9 +574,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
observerRef.current = observer;
const observeUnread = () => {
if (!messagesContainerRef.current) return;
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el) => {
if (!scrollContainerRef.current) return;
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el: Element) => {
const id = el.getAttribute('data-message-id');
if (id && !sentReadIdsRef.current.has(id)) {
observer.observe(el);
@@ -537,9 +589,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}, [activeChat, user?.id]);
useEffect(() => {
if (observerRef.current && messagesContainerRef.current) {
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el) => {
if (observerRef.current && scrollContainerRef.current) {
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el: Element) => {
const id = el.getAttribute('data-message-id');
if (id && !sentReadIdsRef.current.has(id)) {
observerRef.current?.observe(el);
@@ -949,6 +1001,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
try {
await ChatApi.clearChat(activeChat);
useChatStore.getState().clearMessages(activeChat);
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
NotificationStore.useNotificationStore.getState().addNotification('success', t('chatCleared'));
} catch (e) {
console.error(e);
}
@@ -1100,37 +1154,62 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</button>
)}
{pinnedMsg && (
<button
onClick={() => {
const el = document.getElementById(`msg-${pinnedMsg.id}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
}
}}
className="flex items-center gap-3 px-4 py-2 border-b border-outline/10 bg-surface-container-high/60 hover:bg-surface-container-highest transition-colors text-left w-full flex-shrink-0"
>
<Pin size={16} className="text-primary flex-shrink-0 rotate-45" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-primary">{t('pinnedMessage')}</p>
<p className="text-sm text-zinc-300 truncate">
{pinnedMsg.content || (pinnedMsg.media?.length > 0 ? t('media') : '...')}
</p>
</div>
<X
size={16}
className="text-zinc-500 hover:text-white flex-shrink-0 transition-colors"
onClick={(e) => {
e.stopPropagation();
const socket = getSocket();
if (socket && activeChat) {
socket.emit('unpin_message', { messageId: pinnedMsg.id, chatId: activeChat });
{chatPinnedMessages.length > 0 && (
<div className="flex-shrink-0 flex items-center gap-0 border-b border-outline/10 bg-surface-container-high/60 hover:bg-surface-container-high transition-colors overflow-hidden h-[54px] relative">
{/* Cycling progress indicator for multiple pins */}
{chatPinnedMessages.length > 1 && (
<div className="absolute left-1 top-1.5 bottom-1.5 w-0.5 rounded-full bg-white/5 flex flex-col gap-0.5 overflow-hidden">
{chatPinnedMessages.map((_, idx) => (
<div
key={idx}
className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`}
/>
))}
</div>
)}
<button
onClick={() => {
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
if (!currentPin) return;
handleJumpToMessage(currentPin.id, undefined, currentPin.createdAt);
if (chatPinnedMessages.length > 1) {
setPinnedIndex(prev => (prev + 1) % chatPinnedMessages.length);
}
}}
/>
</button>
className={`flex-1 flex items-center gap-3 px-4 py-2 text-left h-full ${chatPinnedMessages.length > 1 ? 'ml-1.5' : ''}`}
>
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Pin size={14} className="text-primary rotate-45" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-black text-primary uppercase tracking-wider">
{t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''}
</p>
<p className="text-sm text-zinc-300 truncate font-medium">
{chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content ||
(chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')}
</p>
</div>
</button>
<div className="flex items-center px-2">
<button
onClick={(e) => {
e.stopPropagation();
const socket = getSocket();
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
if (socket && activeChat && currentPin) {
socket.emit('unpin_message', { messageId: currentPin.id, chatId: activeChat });
}
}}
className="w-8 h-8 rounded-full flex items-center justify-center text-zinc-500 hover:text-white hover:bg-white/5 transition-all"
title={t('unpin' as any) || 'Открепить'}
>
<X size={16} />
</button>
</div>
</div>
)}
<div className="relative flex-1 flex flex-col overflow-hidden">
@@ -1142,7 +1221,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
exit={{ opacity: 0, scale: 0.9, y: -20 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
key="sticky-date"
className="absolute top-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-none"
className="absolute top-6 left-1/2 -translate-x-1/2 z-[999] pointer-events-none"
>
<span className="px-4 py-1.5 rounded-full text-[11px] font-black uppercase tracking-widest text-white bg-black/60 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)] border border-white/10 ring-2 ring-black/20 whitespace-nowrap">
{stickyDate}
@@ -1152,7 +1231,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</AnimatePresence>
<div
ref={messagesContainerRef}
ref={scrollContainerRef}
onScroll={handleScroll}
className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 scroll-smooth-container ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
>
@@ -1253,7 +1332,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</AnimatePresence>
</div>
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe">
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe relative z-50">
<MessageInput chatId={activeChat} />
</footer>
</>
@@ -1266,57 +1345,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
)}
{(() => {
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
cleanup?.();
const tryScroll = () => {
const el = document.getElementById(`msg-${msgId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
return true;
}
return false;
};
if (tryScroll()) return;
if (!activeChat) return;
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...');
const chatStore = useChatStore.getState();
let found = false;
const targetDate = targetCreatedAt ? new Date(targetCreatedAt).getTime() : 0;
for (let i = 0; i < 100; i++) {
const chatMessages = chatStore.messages[activeChat] || [];
const oldestLoaded = chatMessages.length > 0 ? new Date(chatMessages[0].createdAt).getTime() : Date.now();
if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) {
if (tryScroll()) { found = true; break; }
}
if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break;
await chatStore.loadMessages(activeChat, false, true);
await new Promise(resolve => setTimeout(resolve, 150));
if (tryScroll()) {
found = true;
break;
}
if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) {
if (i > 10) break;
}
}
if (!found) {
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
}
};
return (
<>
<AnimatePresence>

View File

@@ -0,0 +1,198 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Plus, Trash2, BarChart2 } from 'lucide-react';
import { createPortal } from 'react-dom';
import { useLang } from '../../../../core/infrastructure/i18n';
interface CreatePollModalProps {
isOpen: boolean;
onClose: () => void;
onSend: (data: {
question: string;
options: string[];
isAnonymous: boolean;
allowMultiple: boolean;
}) => void;
}
export default function CreatePollModal({ isOpen, onClose, onSend }: CreatePollModalProps) {
const { t } = useLang();
const [question, setQuestion] = useState('');
const [options, setOptions] = useState(['', '']);
const [isAnonymous, setIsAnonymous] = useState(true);
const [allowMultiple, setAllowMultiple] = useState(false);
if (!isOpen) return null;
const handleAddOption = () => {
if (options.length < 10) {
setOptions([...options, '']);
}
};
const handleRemoveOption = (index: number) => {
if (options.length > 2) {
const newOptions = [...options];
newOptions.splice(index, 1);
setOptions(newOptions);
}
};
const handleOptionChange = (index: number, value: string) => {
const newOptions = [...options];
newOptions[index] = value;
setOptions(newOptions);
};
const isValid = question.trim() && options.filter(o => o.trim()).length >= 2;
const handleSubmit = () => {
if (!isValid) return;
onSend({
question: question.trim(),
options: options.filter(o => o.trim()),
isAnonymous,
allowMultiple
});
// Reset and close
setQuestion('');
setOptions(['', '']);
setIsAnonymous(true);
setAllowMultiple(false);
onClose();
};
const modalContent = (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[100000] flex items-center justify-center p-4 isolate">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/80 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 30 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 30 }}
className="relative w-full max-w-md max-h-[85vh] bg-[#1a1a1a] rounded-[2.5rem] shadow-2xl flex flex-col overflow-hidden border border-white/10"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-primary/20 flex items-center justify-center">
<BarChart2 className="text-primary" size={22} />
</div>
<h3 className="text-xl font-bold text-white tracking-tight">
{t('createPoll')}
</h3>
</div>
<button
onClick={onClose}
className="w-10 h-10 rounded-full flex items-center justify-center text-zinc-400 hover:bg-white/5 hover:text-white transition-all"
>
<X size={22} />
</button>
</div>
{/* Content */}
<div className="flex-1 p-6 space-y-7 overflow-y-auto custom-scrollbar">
{/* Question */}
<div className="space-y-2.5">
<label className="text-[11px] font-black uppercase tracking-[0.1em] text-primary/80 px-1">
{t('pollQuestion')}
</label>
<textarea
autoFocus
placeholder={t('pollQuestionPlaceholder')}
value={question}
onChange={(e) => setQuestion(e.target.value)}
className="w-full bg-white/[0.03] border border-white/5 rounded-2xl p-4 text-sm text-white placeholder:text-white/20 focus:outline-none focus:border-primary/30 focus:bg-white/[0.06] transition-all resize-none min-h-[120px]"
/>
</div>
{/* Options */}
<div className="space-y-4">
<label className="text-[11px] font-black uppercase tracking-[0.1em] text-primary/80 px-1">
{t('pollOptions')}
</label>
<div className="space-y-2.5">
{options.map((option, idx) => (
<div key={idx} className="flex gap-2.5 group">
<input
placeholder={`${t('pollOption')} ${idx + 1}`}
value={option}
onChange={(e) => handleOptionChange(idx, e.target.value)}
className="flex-1 bg-white/[0.03] border border-white/5 rounded-2xl px-5 py-3.5 text-sm text-white placeholder:text-white/20 focus:outline-none focus:border-primary/30 focus:bg-white/[0.06] transition-all"
/>
{options.length > 2 && (
<button
onClick={() => handleRemoveOption(idx)}
className="w-12 h-12 rounded-2xl bg-red-500/10 text-red-400 flex items-center justify-center hover:bg-red-500/20 transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 size={20} />
</button>
)}
</div>
))}
</div>
{options.length < 10 && (
<button
onClick={handleAddOption}
className="flex items-center gap-2.5 text-sm font-bold text-primary hover:text-primary/80 transition-colors px-1 h-10"
>
<Plus size={20} />
<span>{t('addOption')}</span>
</button>
)}
</div>
{/* Settings */}
<div className="pt-2 space-y-4">
<label className="text-[11px] font-black uppercase tracking-[0.1em] text-zinc-500 px-1">
{t('pollSettings')}
</label>
<div className="space-y-3">
<label className="flex items-center justify-between p-4.5 rounded-[1.25rem] bg-white/[0.02] border border-white/5 cursor-pointer hover:bg-white/[0.05] transition-all group">
<span className="text-[15px] font-medium text-zinc-300 group-hover:text-white transition-colors">{t('anonymousVoting')}</span>
<input
type="checkbox"
checked={isAnonymous}
onChange={(e) => setIsAnonymous(e.target.checked)}
className="w-5 h-5 rounded-md accent-primary"
/>
</label>
<label className="flex items-center justify-between p-4.5 rounded-[1.25rem] bg-white/[0.02] border border-white/5 cursor-pointer hover:bg-white/[0.05] transition-all group">
<span className="text-[15px] font-medium text-zinc-300 group-hover:text-white transition-colors">{t('multipleAnswers')}</span>
<input
type="checkbox"
checked={allowMultiple}
onChange={(e) => setAllowMultiple(e.target.checked)}
className="w-5 h-5 rounded-md accent-primary"
/>
</label>
</div>
</div>
</div>
{/* Footer */}
<div className="p-6 pt-3">
<button
disabled={!isValid}
onClick={handleSubmit}
className="w-full h-14 rounded-2xl bg-primary text-white text-[16px] font-black shadow-2xl shadow-primary/20 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-30 disabled:grayscale disabled:scale-100 transition-all flex items-center justify-center gap-3"
>
<BarChart2 size={22} className="fill-white/20" />
{t('createPoll')}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
return createPortal(modalContent, document.body);
}

View File

@@ -24,6 +24,7 @@ import {
PhoneMissed,
PhoneIncoming,
PhoneOutgoing,
BarChart2,
} from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore';
@@ -164,7 +165,7 @@ function MessageBubble({
? otherMember?.user.displayName || otherMember?.user.userName || otherMember?.user.username || ''
: '';
const isPinned = pinnedMessages[message.chatId]?.id === message.id;
const isPinned = (pinnedMessages[message.chatId] || []).some(m => m.id === message.id);
const handlePin = () => {
const socket = getSocket();
@@ -326,7 +327,8 @@ function MessageBubble({
</div>
<div className="flex flex-col items-end justify-between self-stretch pt-0.5">
<div className="text-[10px] font-bold tracking-tight text-white/30 tabular-nums uppercase">
<div className="text-[10px] font-bold tracking-tight text-white/30 tabular-nums uppercase flex items-center gap-1">
{isPinned && <Pin size={10} className="rotate-45 text-primary fill-primary/20" />}
{timeStr}
</div>
{isMine && (
@@ -687,6 +689,7 @@ function MessageBubble({
{!message.content && (
<div className="absolute bottom-1.5 right-1.5 z-10 pointer-events-none flex justify-end">
<span className="text-[10px] font-bold text-on-surface-variant/40 bg-surface-container-highest/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-md pointer-events-auto">
{isPinned && <Pin size={10} className="rotate-45 text-primary fill-primary/40" />}
{timeStr}
{isMine && !message.scheduledAt && (
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-primary fill-1' : 'text-on-surface-variant/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
@@ -885,6 +888,58 @@ function MessageBubble({
);
})}
{/* Опрос */}
{message.type === 'poll' && message.pollOptions && (
<div className={`p-1.5 space-y-4 min-w-[260px] max-w-full ${isMine ? 'text-[#0a0a0a]' : 'text-zinc-200'}`}>
<div className="space-y-1">
<h4 className="text-[15px] font-bold leading-tight flex items-start gap-2">
<BarChart2 size={18} className="mt-0.5 shrink-0 opacity-60" />
{message.content}
</h4>
<p className="text-[11px] font-medium opacity-50 uppercase tracking-widest pl-7">
{message.pollIsMultipleChoice ? t('multipleAnswers') : t('singleAnswer' as any) || 'Выберите один вариант'}
</p>
</div>
<div className="space-y-2">
{message.pollOptions.map((opt, idx) => {
const totalVotes = message.pollOptions!.reduce((sum, o) => sum + (o as any).voteCount, 0);
const percent = totalVotes > 0 ? Math.round(((opt as any).voteCount / totalVotes) * 100) : 0;
return (
<button
key={idx}
className={`w-full group/opt relative rounded-2xl border transition-all duration-300 overflow-hidden text-left p-3 flex flex-col gap-1.5
${isMine
? 'bg-[#0a0a0a]/5 border-[#0a0a0a]/10 hover:bg-[#0a0a0a]/10'
: 'bg-white/5 border-white/5 hover:bg-white/10'}`}
>
<div className="flex items-center justify-between relative z-10">
<span className="text-[14px] font-semibold truncate flex-1">{(opt as any).text}</span>
<span className="text-[13px] font-black tabular-nums opacity-80">{percent}%</span>
</div>
<div className="relative h-1.5 w-full bg-white/5 rounded-full overflow-hidden z-10">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${percent}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
className={`absolute inset-0 rounded-full ${isMine ? 'bg-[#0a0a0a]/40' : 'bg-primary'}`}
/>
</div>
<div className="flex items-center justify-between relative z-10">
<span className="text-[10px] font-bold opacity-40 uppercase tracking-wider">
{(opt as any).voteCount} {t('votes' as any) || 'голосов'}
</span>
</div>
</button>
);
})}
</div>
</div>
)}
{/* Текст */}
{message.content && (() => {
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
@@ -904,6 +959,7 @@ function MessageBubble({
<span className={`text-[10px] font-bold flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-[#0a0a0a]/50' : 'text-on-surface-variant/40'}`}>
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
{message.scheduledAt && <span className="material-symbols-outlined text-[12px] text-amber-400 mr-0.5">schedule</span>}
{isPinned && <Pin size={10} className={`rotate-45 ${isMine ? 'text-[#0a0a0a]/60 fill-[#0a0a0a]/20' : 'text-primary fill-primary/20'} mr-0.5`} />}
{timeStr}
{isMine && !message.scheduledAt && (
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-[#0a0a0a]/80 fill-1' : 'text-[#0a0a0a]/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>

View File

@@ -16,6 +16,7 @@ import {
ChevronRight,
Calendar,
Check,
BarChart2,
} from 'lucide-react';
import { useChatStore } from '../../application/chatStore';
import { useAuthStore } from '../../../auth/application/authStore';
@@ -25,6 +26,7 @@ import { useLang } from '../../../../core/infrastructure/i18n';
import { AUDIO_EXTENSIONS, MAX_FILE_SIZE, type ChatMember } from '../../../../core/domain/types';
import { useNotificationStore } from '../../../../core/application/stores/notificationStore';
import EmojiPicker from './EmojiPicker';
import CreatePollModal from './CreatePollModal';
interface Attachment {
file: File;
@@ -37,7 +39,7 @@ interface MessageInputProps {
}
export default function MessageInput({ chatId }: MessageInputProps) {
const { user } = useAuthStore();
const { user, config } = useAuthStore();
const { t } = useLang();
const { replyTo, editingMessage, setReplyTo, setEditingMessage, getDraft, setDraft, chats } = useChatStore();
const [text, setText] = useState(() => getDraft(chatId));
@@ -65,6 +67,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
const [scheduleCalMonth, setScheduleCalMonth] = useState(new Date().getMonth());
const [scheduleCalYear, setScheduleCalYear] = useState(new Date().getFullYear());
const [scheduleToast, setScheduleToast] = useState<string | null>(null);
const [showPollModal, setShowPollModal] = useState(false);
// Filtered members for @mention
const filteredMembers = mentionQuery !== null && isGroup
@@ -256,6 +259,27 @@ export default function MessageInput({ chatId }: MessageInputProps) {
setDraft(chatId, '');
};
const handleSendPoll = (poll: {
question: string;
options: string[];
isAnonymous: boolean;
allowMultiple: boolean;
}) => {
const socket = getSocket();
if (!socket) return;
socket.emit('send_message', {
chatId,
content: poll.question,
type: 'poll',
pollOptions: poll.options,
pollIsAnonymous: poll.isAnonymous,
pollAllowMultipleAnswers: poll.allowMultiple,
replyToId: replyTo?.id || null,
});
setReplyTo(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Handle @mention navigation
if (mentionQuery !== null && filteredMembers.length > 0) {
@@ -746,31 +770,31 @@ export default function MessageInput({ chatId }: MessageInputProps) {
<div className="relative flex-shrink-0 self-end mb-1">
<button
onClick={() => setShowAttachMenu(!showAttachMenu)}
className="w-10 h-10 rounded-full text-on-surface-variant/60 hover:text-primary transition-all flex items-center justify-center p-0"
className="w-10 h-10 rounded-full text-zinc-400 hover:text-primary transition-all flex items-center justify-center p-0"
>
<Paperclip size={24} strokeWidth={1.5} />
</button>
<AnimatePresence>
{showAttachMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAttachMenu(false)} />
<div className="fixed inset-0 z-[10000]" onClick={() => setShowAttachMenu(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 15 }}
className="absolute bottom-[calc(100%+12px)] left-0 w-52 rounded-[1.5rem] glass-strong shadow-2xl z-50 p-2 border border-white/10 backdrop-blur-3xl"
className="absolute bottom-[calc(100%+12px)] left-0 w-52 rounded-[2.5rem] bg-[#1e1e1e]/95 shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-[10001] p-2 border border-white/10 backdrop-blur-3xl"
>
<button
onClick={() => imageInputRef.current?.click()}
onClick={() => { imageInputRef.current?.click(); setShowAttachMenu(false); }}
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group"
>
<div className="w-10 h-10 rounded-full bg-linear-to-br from-primary/20 to-primary-container/20 flex items-center justify-center ring-1 ring-primary/30 group-hover:scale-110 transition-transform shadow-inner">
<ImageIcon size={18} className="text-knot-400" />
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary/20 to-primary-container/20 flex items-center justify-center ring-1 ring-primary/30 group-hover:scale-110 transition-transform shadow-inner">
<ImageIcon size={18} className="text-primary" />
</div>
{t('photoVideo')}
</button>
<button
onClick={() => fileInputRef.current?.click()}
onClick={() => { fileInputRef.current?.click(); setShowAttachMenu(false); }}
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group mt-1"
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400/20 to-teal-500/20 flex items-center justify-center ring-1 ring-emerald-400/30 group-hover:scale-110 transition-transform shadow-inner">
@@ -778,6 +802,20 @@ export default function MessageInput({ chatId }: MessageInputProps) {
</div>
{t('file')}
</button>
{(config?.messages?.allowPolls ?? true) && (
<button
onClick={() => {
setShowPollModal(true);
setShowAttachMenu(false);
}}
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group mt-1"
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-400/20 to-indigo-500/20 flex items-center justify-center ring-1 ring-purple-400/30 group-hover:scale-110 transition-transform shadow-inner">
<BarChart2 size={18} className="text-purple-400" />
</div>
{t('poll')}
</button>
)}
</motion.div>
</>
)}
@@ -1052,6 +1090,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
</motion.div>
)}
</AnimatePresence>
<CreatePollModal
isOpen={showPollModal}
onClose={() => setShowPollModal(false)}
onSend={handleSendPoll}
/>
</div>
);
}