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 => (
-
+
))}
@@ -115,10 +121,10 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
)}
- {/* Actions */}
-
+ {/* Actions — visible only for active message */}
+
{isMine && message.type === 'text' && (