Опросы

This commit is contained in:
Халимов Рустам
2026-04-07 11:11:35 +03:00
parent c45f4db61c
commit 852efa090e
25 changed files with 530 additions and 175 deletions

View File

@@ -43,6 +43,7 @@ interface ChatState {
clearMessages: (chatId: string) => void;
setPinnedMessage: (chatId: string, message: Message) => void;
removePinnedMessage: (chatId: string, messageId: string, newPinned?: Message[] | null) => void;
jumpToMessage: (chatId: string, sequenceId: number) => Promise<void>;
clearStore: () => void;
}
@@ -188,13 +189,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
updateMessage: (message) => {
set((state) => {
const chatMessages = state.messages[message.chatId] || [];
const updatedMessages = chatMessages.map((m) => (m.id === message.id ? message : m));
const updatedMessages = chatMessages.map((m) => (m.id === message.id ? { ...m, ...message } : m));
const updatedChats = state.chats.map((chat) => {
if (chat.id === message.chatId) {
return {
...chat,
messages: chat.messages?.map((m) => (m.id === message.id ? message : m)),
messages: chat.messages?.map((m) => (m.id === message.id ? { ...m, ...message } : m)),
};
}
return chat;
@@ -545,6 +546,25 @@ export const useChatStore = create<ChatState>((set, get) => ({
});
},
jumpToMessage: async (chatId, sequenceId) => {
try {
set({ isLoadingMessages: true });
const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50);
set((state) => ({
messages: { ...state.messages, [chatId]: fetched },
// Since we jumped, we assume there is more history to load above
hasMoreMessages: { ...state.hasMoreMessages, [chatId]: true },
isLoadingMessages: false,
}));
} catch (error: any) {
console.error('Jump to message error:', error);
set({ isLoadingMessages: false });
const { addNotification } = (await import('../../../core/application/stores/notificationStore')).useNotificationStore.getState();
addNotification('error', error.message || 'Failed to jump to message');
}
},
clearStore: () => {
set({
chats: [],

View File

@@ -20,9 +20,13 @@ export class ChatApi {
});
}
static async getMessages(chatId: string, cursor?: string) {
const params = cursor ? `?cursor=${cursor}` : '';
return httpClient.request<Message[]>(`/messages/chat/${chatId}${params}`);
static async getMessages(chatId: string, cursor?: string, pivot?: number, limit?: number) {
const params = new URLSearchParams();
if (cursor) params.append('cursor', cursor);
if (pivot) params.append('pivot', pivot.toString());
if (limit) params.append('limit', limit.toString());
const query = params.toString() ? `?${params.toString()}` : '';
return httpClient.request<Message[]>(`/messages/chat/${chatId}${query}`);
}
static async uploadFile(file: File) {

View File

@@ -210,6 +210,10 @@ export default function ChatPage() {
removePinnedMessage(data.chatId, data.messageId);
});
socket.on('poll_updated', (message: Message) => {
updateMessage(message);
});
socket.on('call_incoming', async (data: CallInfo) => {
// Use callerInfo from server if available, otherwise look up from chats
let callerInfo: UserBasic | null = data.callerInfo || null;

View File

@@ -79,8 +79,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
cleanup?.();
const handleJumpToMessage = async (msgId: string, sequenceId?: number) => {
const tryScroll = () => {
const el = document.getElementById(`msg-${msgId}`);
if (el) {
@@ -99,34 +98,26 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
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 (sequenceId !== undefined) {
await chatStore.jumpToMessage(activeChat, sequenceId);
// Wait a bit for React to render
setTimeout(() => {
if (!tryScroll()) {
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
}
}, 300);
} else {
// Old fallback loop if no sequenceId
let found = false;
for (let i = 0; i < 50; i++) {
await chatStore.loadMessages(activeChat, false, true);
await new Promise(resolve => setTimeout(resolve, 150));
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 (!found) {
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
}
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);
@@ -455,17 +446,31 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const st = container.scrollTop;
const isScrollingUp = st < lastScrollTopRef.current;
lastScrollTopRef.current = st;
const stChanged = st !== lastScrollTopRef.current;
// 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), isScrollingUp ? 1500 : 1000);
if (st > 100 && stChanged) {
// Показываем плашку только при прокрутке или если она уже активна
// При прокрутке вверх она должна быть видна всегда
if (isScrollingUp) {
setShowStickyDate(true);
if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current);
// Таймер на 2 сек запустится только после остановки скролла
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 2000);
} else {
// При прокрутке вниз плашка обычно скрывается быстрее
if (stickyDateTimerRef.current) {
clearTimeout(stickyDateTimerRef.current);
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 800);
}
}
} else if (st <= 100) {
setShowStickyDate(false);
if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current);
}
lastScrollTopRef.current = st;
const containerRect = container.getBoundingClientRect();
const messageElements = container.querySelectorAll('[data-message-id]');
@@ -1172,7 +1177,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
onClick={() => {
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
if (!currentPin) return;
handleJumpToMessage(currentPin.id, undefined, currentPin.createdAt);
handleJumpToMessage(currentPin.id, currentPin.sequenceId);
if (chatPinnedMessages.length > 1) {
setPinnedIndex(prev => (prev + 1) % chatPinnedMessages.length);
}
@@ -1353,7 +1358,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
userId={profileUserId}
chatId={activeChat || undefined}
onClose={() => setProfileUserId(null)}
onGoToMessage={(msgId: any, createdAt: string) => handleJumpToMessage(msgId, () => setProfileUserId(null), createdAt)}
onGoToMessage={(msgId: any) => { handleJumpToMessage(msgId); setProfileUserId(null); }}
isSelf={profileUserId === user?.id}
/>
)}
@@ -1364,7 +1369,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<GroupSettings
chat={chat}
onClose={() => setShowGroupSettings(false)}
onGoToMessage={(msgId) => handleJumpToMessage(msgId, () => setShowGroupSettings(false))}
onGoToMessage={(msgId) => { handleJumpToMessage(msgId); setShowGroupSettings(false); }}
/>
)}
</AnimatePresence>

View File

@@ -35,7 +35,7 @@ import { getCroppedImg } from '../../../../core/infrastructure/imageCrop';
interface GroupSettingsProps {
chat: Chat;
onClose: () => void;
onGoToMessage?: (messageId: string) => void;
onGoToMessage?: (messageId: string, sequenceId?: number) => void;
}
export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSettingsProps) {
@@ -256,7 +256,8 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
url: getMediaUrl(m.url),
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
messageId: msg.id,
createdAt: msg.createdAt
createdAt: msg.createdAt,
sequenceId: msg.sequenceId
})));
const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({
@@ -264,7 +265,8 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
url: getMediaUrl(m.url),
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
messageId: msg.id,
createdAt: msg.createdAt
createdAt: msg.createdAt,
sequenceId: msg.sequenceId
})));
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -669,7 +671,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
/>
)}
<button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId, m.sequenceId); }}
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
>
{t('showInChat')}
@@ -714,7 +716,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
/>
)}
<button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId, m.sequenceId); }}
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
>
{t('showInChat')}
@@ -749,7 +751,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
<Download size={14} className="text-zinc-600" />
</a>
<button
onClick={() => onGoToMessage?.(msg.id)}
onClick={() => onGoToMessage?.(msg.id, msg.sequenceId)}
className="absolute right-10 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-300 text-[11px] font-medium opacity-0 group-hover/file:opacity-100 transition-opacity"
>
{t('showInChat')}
@@ -781,7 +783,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
))}
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
<button
onClick={() => onGoToMessage?.(msg.id)}
onClick={() => onGoToMessage?.(msg.id, msg.sequenceId)}
className="absolute right-4 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg bg-black/40 hover:bg-knot-500/20 text-zinc-300 hover:text-white text-[11px] font-medium opacity-0 group-hover:opacity-100 transition-all shadow-md z-10"
>
{t('showInChat')}

View File

@@ -34,6 +34,7 @@ import { extractWaveform, getMediaUrl, generateAvatarColor, getInitials } from '
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import LinkPreview from './LinkPreview';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
interface MessageBubbleProps {
message: Message;
@@ -892,56 +893,141 @@ 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">
<h4 className="text-[15px] font-bold leading-tight flex items-start gap-2 whitespace-pre-wrap break-words">
<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 className="flex items-center justify-between pl-7 pr-2">
<p className="text-[11px] font-medium opacity-50 uppercase tracking-widest">
{message.pollIsMultipleChoice ? t('multipleAnswers') : t('singleAnswer')}
</p>
{message.pollIsAnonymous && (
<span className="text-[10px] font-black uppercase tracking-tighter bg-black/5 px-2 py-0.5 rounded-sm opacity-40">
{t('anonymous')}
</span>
)}
</div>
</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>
);
})}
{(() => {
const hasVoted = (message.userVotedOptionIds && message.userVotedOptionIds.length > 0) ||
message.pollOptions?.some(o => o.voterIds?.includes(user?.id || ''));
const totalVotes = message.pollOptions!.reduce((sum, o) => sum + (o.voteCount || 0), 0);
return message.pollOptions.map((opt, idx) => {
const isVotedByMe = (message.userVotedOptionIds?.includes(opt.id)) ||
opt.voterIds?.includes(user?.id || '');
const percent = totalVotes > 0 ? Math.round(((opt.voteCount || 0) / totalVotes) * 100) : 0;
return (
<button
key={opt.id || idx}
disabled={hasVoted && !message.pollIsMultipleChoice} // If already voted and not multiple choice, disable.
onClick={(e) => {
e.stopPropagation();
if (hasVoted && !message.pollIsMultipleChoice) return;
// Optimistic update for better UX
const currentVoted = message.userVotedOptionIds || [];
if (!currentVoted.includes(opt.id)) {
useChatStore.getState().updateMessage({
...message,
userVotedOptionIds: message.pollIsMultipleChoice ? [...currentVoted, opt.id] : [opt.id]
});
}
const socket = getSocket();
if (socket) {
socket.emit('vote_poll', {
messageId: message.id,
chatId: message.chatId,
optionId: opt.id
});
}
}}
className={`w-full group/opt relative rounded-2xl border transition-all duration-300 text-left p-3 flex flex-col gap-1.5
${isMine
? 'bg-[#0a0a0a]/5 border-[#0a0a0a]/10 hover:bg-[#0a0a0a]/10 active:scale-[0.98] hover:z-20'
: 'bg-white/5 border-white/5 hover:bg-white/10 active:scale-[0.98] hover:z-20'}`}
>
<div className="flex items-center justify-between relative z-10">
<div className="flex items-center gap-2 truncate flex-1">
{isVotedByMe && <Check size={14} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} />}
<span className="text-[14px] font-semibold truncate">{opt.text}</span>
</div>
{hasVoted && (
<span className="text-[13px] font-black tabular-nums opacity-80">{percent}%</span>
)}
</div>
{hasVoted && (
<>
<div className="relative h-1.5 w-full bg-white/5 rounded-full overflow-hidden z-10">
<motion.div
initial={false}
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 font-bold uppercase tracking-widest text-[10px]">
<span className="opacity-40">{opt.voteCount || 0} {t('votes')}</span>
{opt.voters && opt.voters.length > 0 && (
<div className="relative group/voters flex -space-x-1.5 hover:space-x-0.5 transition-all duration-300">
{opt.voters.slice(0, 5).map((voter) => (
<Avatar
key={voter.id}
src={voter.avatar}
name={voter.displayName}
size="xs"
className="ring-1 ring-white/20 shadow-lg"
/>
))}
{opt.voters.length > 5 && (
<div className="w-4 h-4 rounded-lg bg-white/10 flex items-center justify-center text-[7px] font-black tabular-nums ring-1 ring-white/20 text-white/50">
+{opt.voters.length - 5}
</div>
)}
{/* Подробный список при наведении */}
<div className="absolute bottom-full right-0 mb-3 opacity-0 group-hover/voters:opacity-100 transition-all duration-300 pointer-events-none scale-95 group-hover/voters:scale-100 origin-bottom-right z-50">
<div className="bg-[#1b1b1b] shadow-2xl rounded-2xl p-2 border border-white/10 min-w-[160px] backdrop-blur-xl">
<div className="space-y-1">
{opt.voters.map(v => (
<div key={v.id} className="flex items-center gap-2.5 p-1.5 hover:bg-white/10 rounded-xl transition-colors">
<Avatar src={v.avatar} name={v.displayName} size="xs" />
<span className="text-[11px] font-bold text-white/90 truncate">{v.displayName}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
</>
)}
</button>
);
});
})()}
</div>
{(() => {
const hasVoted = (message.userVotedOptionIds && message.userVotedOptionIds.length > 0) ||
message.pollOptions?.some(o => o.voterIds?.includes(user?.id || ''));
const totalVotes = message.pollOptions!.reduce((sum, o) => sum + (o.voteCount || 0), 0);
if (hasVoted) {
return <div className="text-[10px] font-black opacity-30 mt-2 px-2">ВСЕГО ПРОГОЛОСОВАЛО: {totalVotes}</div>
}
return null;
})()}
</div>
)}
{/* Текст */}
{message.content && (() => {
{message.content && message.type !== 'poll' && (() => {
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
return (

View File

@@ -802,7 +802,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
</div>
{t('file')}
</button>
{(config?.messages?.allowPolls ?? true) && (
{(config?.messages?.allowPolls ?? true) && isGroup && (
<button
onClick={() => {
setShowPollModal(true);

View File

@@ -24,7 +24,7 @@ interface UserProfileProps {
onMessage?: (userId: string) => void;
isSelf?: boolean;
chatId?: string;
onGoToMessage?: (msgId: any, createdAt: string) => Promise<void>;
onGoToMessage?: (msgId: string, sequenceId?: number) => void;
}
type TabType = 'media' | 'gif' | 'files' | 'links';
@@ -338,7 +338,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
<div className="absolute top-3 right-3 opacity-0 group-hover/item:opacity-100 transition-opacity z-20">
<button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(item.id, item.createdAt); }}
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(item.id, item.sequenceId); }}
className="p-2.5 rounded-2xl bg-primary/95 text-white shadow-xl backdrop-blur-md hover:scale-110 active:scale-95 transition-all"
title={t('showInChat')}
>
@@ -380,7 +380,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
<div className="flex items-center gap-2">
<button
onClick={() => onGoToMessage?.(item.id, item.createdAt)}
onClick={() => onGoToMessage?.(item.id, item.sequenceId)}
className="p-3 rounded-2xl bg-white/5 opacity-0 group-hover/row:opacity-100 text-white/40 hover:text-white hover:bg-primary/20 transition-all"
title={t('showInChat')}
>