diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de94723..ac0e657 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,24 @@ import { useStore } from './store'; import { wsClient } from './api/ws'; import api from './api/client'; +let _audioCtx: AudioContext | null = null; +function playBeep() { + try { + if (!_audioCtx) _audioCtx = new AudioContext(); + const ctx = _audioCtx; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + osc.frequency.value = 880; + gain.gain.setValueAtTime(0.12, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.35); + } catch {} +} + function AuthGuard({ children }: { children: React.ReactNode }) { const user = useStore(s => s.user); return user ? <>{children} : ; @@ -68,6 +86,11 @@ export default function App() { wsClient.on('new_message', (msg) => { addMessage(msg); + const state = useStore.getState(); + if (msg.sender?.id !== state.user?.id) { + const chat = state.chats.find(c => c.id === msg.chatId); + if (!chat?.isMuted) playBeep(); + } }); wsClient.on('message_edited', (msg) => { diff --git a/frontend/src/components/MessageItem.tsx b/frontend/src/components/MessageItem.tsx index 947e32f..17c83df 100644 --- a/frontend/src/components/MessageItem.tsx +++ b/frontend/src/components/MessageItem.tsx @@ -1,7 +1,6 @@ import { format } from 'date-fns'; import { ru } from 'date-fns/locale'; import { Check, CheckCheck, Pencil, Trash2, Reply, Download } from 'lucide-react'; -import { useState } from 'react'; import { Message } from '../types'; import Avatar from './Avatar'; @@ -11,19 +10,19 @@ interface Props { showAvatar: boolean; isGroup: boolean; isRead: boolean; + showActions: boolean; + onShowActions: () => void; onReply: (msg: Message) => void; onDelete: (id: string) => void; onEdit: (msg: Message) => void; } -export default function MessageItem({ message, isMine, showAvatar, isGroup, isRead, onReply, onDelete, onEdit }: Props) { - const [showMenu, setShowMenu] = useState(false); - +export default function MessageItem({ message, isMine, showAvatar, isGroup, isRead, showActions, onShowActions, onReply, onDelete, onEdit }: Props) { if (message.isDeleted) { return (
{showAvatar && !isMine ?
: null} -
+
Сообщение удалено
@@ -37,8 +36,8 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe return (
setShowMenu(false)} + className={`flex items-end gap-2 mb-1 ${isMine ? 'flex-row-reverse' : ''}`} + onMouseEnter={onShowActions} > {/* Avatar */} {!isMine && isGroup ? ( @@ -48,7 +47,10 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe ) : null} {/* Bubble */} -
+
{/* Sender name in group */} {!isMine && isGroup && showAvatar && message.sender && ( @@ -58,7 +60,7 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe {/* Reply preview */} {message.replyTo && ( -
+
{message.replyTo.senderName}
{message.replyTo.content}
@@ -66,10 +68,14 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe {/* Images */} {imageAttachments.length > 0 && ( -
+
{imageAttachments.map(a => ( - {a.originalName} + {a.originalName} ))}
@@ -115,10 +121,10 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe )}
- {/* Actions */} -
+ {/* Actions — visible only for active message */} +
{isMine && message.type === 'text' && (