fix: message actions per-tap only, image sizing, notification sound

- Message action buttons now show per-message (tap on mobile, hover on desktop)
  via state instead of CSS group-hover — fixes all-at-once display on iOS
- Click on message list background closes active action menu
- Images: object-contain + max-w-[240px] so stickers display correctly
- Add Web Audio beep when new message arrives from others (respects mute)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ai
2026-05-25 14:59:32 +03:00
parent f4a1187fdf
commit 63f280f754
3 changed files with 49 additions and 16 deletions

View File

@@ -25,6 +25,24 @@ import { useStore } from './store';
import { wsClient } from './api/ws'; import { wsClient } from './api/ws';
import api from './api/client'; 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 }) { function AuthGuard({ children }: { children: React.ReactNode }) {
const user = useStore(s => s.user); const user = useStore(s => s.user);
return user ? <>{children}</> : <Navigate to="/login" replace />; return user ? <>{children}</> : <Navigate to="/login" replace />;
@@ -68,6 +86,11 @@ export default function App() {
wsClient.on('new_message', (msg) => { wsClient.on('new_message', (msg) => {
addMessage(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) => { wsClient.on('message_edited', (msg) => {

View File

@@ -1,7 +1,6 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ru } from 'date-fns/locale'; import { ru } from 'date-fns/locale';
import { Check, CheckCheck, Pencil, Trash2, Reply, Download } from 'lucide-react'; import { Check, CheckCheck, Pencil, Trash2, Reply, Download } from 'lucide-react';
import { useState } from 'react';
import { Message } from '../types'; import { Message } from '../types';
import Avatar from './Avatar'; import Avatar from './Avatar';
@@ -11,19 +10,19 @@ interface Props {
showAvatar: boolean; showAvatar: boolean;
isGroup: boolean; isGroup: boolean;
isRead: boolean; isRead: boolean;
showActions: boolean;
onShowActions: () => void;
onReply: (msg: Message) => void; onReply: (msg: Message) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onEdit: (msg: Message) => void; onEdit: (msg: Message) => void;
} }
export default function MessageItem({ message, isMine, showAvatar, isGroup, isRead, onReply, onDelete, onEdit }: Props) { export default function MessageItem({ message, isMine, showAvatar, isGroup, isRead, showActions, onShowActions, onReply, onDelete, onEdit }: Props) {
const [showMenu, setShowMenu] = useState(false);
if (message.isDeleted) { if (message.isDeleted) {
return ( return (
<div className={`flex items-end gap-2 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}> <div className={`flex items-end gap-2 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}>
{showAvatar && !isMine ? <div className="w-8" /> : null} {showAvatar && !isMine ? <div className="w-8" /> : null}
<div className={`px-3 py-2 rounded-2xl text-sm italic text-gray-400 bg-gray-100 max-w-xs`}> <div className="px-3 py-2 rounded-2xl text-sm italic text-gray-400 bg-gray-100 max-w-xs">
Сообщение удалено Сообщение удалено
</div> </div>
</div> </div>
@@ -37,8 +36,8 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
return ( return (
<div <div
className={`flex items-end gap-2 mb-1 group ${isMine ? 'flex-row-reverse' : ''}`} className={`flex items-end gap-2 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}
onMouseLeave={() => setShowMenu(false)} onMouseEnter={onShowActions}
> >
{/* Avatar */} {/* Avatar */}
{!isMine && isGroup ? ( {!isMine && isGroup ? (
@@ -48,7 +47,10 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
) : null} ) : null}
{/* Bubble */} {/* Bubble */}
<div className={`relative max-w-[70%] lg:max-w-[60%] ${isMine ? 'items-end' : 'items-start'} flex flex-col`}> <div
className={`relative max-w-[70%] lg:max-w-[60%] ${isMine ? 'items-end' : 'items-start'} flex flex-col`}
onClick={onShowActions}
>
{/* Sender name in group */} {/* Sender name in group */}
{!isMine && isGroup && showAvatar && message.sender && ( {!isMine && isGroup && showAvatar && message.sender && (
<span className="text-xs font-medium mb-0.5 px-1" style={{ color: message.sender.avatarColor }}> <span className="text-xs font-medium mb-0.5 px-1" style={{ color: message.sender.avatarColor }}>
@@ -58,7 +60,7 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
{/* Reply preview */} {/* Reply preview */}
{message.replyTo && ( {message.replyTo && (
<div className={`mb-1 px-3 py-1.5 rounded-xl text-xs border-l-2 border-blue-400 bg-blue-50 max-w-full`}> <div className="mb-1 px-3 py-1.5 rounded-xl text-xs border-l-2 border-blue-400 bg-blue-50 max-w-full">
<div className="font-medium text-blue-600">{message.replyTo.senderName}</div> <div className="font-medium text-blue-600">{message.replyTo.senderName}</div>
<div className="text-gray-600 truncate">{message.replyTo.content}</div> <div className="text-gray-600 truncate">{message.replyTo.content}</div>
</div> </div>
@@ -66,10 +68,14 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
{/* Images */} {/* Images */}
{imageAttachments.length > 0 && ( {imageAttachments.length > 0 && (
<div className="mb-1 rounded-xl overflow-hidden"> <div className="mb-1">
{imageAttachments.map(a => ( {imageAttachments.map(a => (
<a key={a.id || a.filename} href={a.url} target="_blank" rel="noopener noreferrer"> <a key={a.id || a.filename} href={a.url} target="_blank" rel="noopener noreferrer">
<img src={a.url} alt={a.originalName} className="max-w-full max-h-64 object-cover rounded-xl" /> <img
src={a.url}
alt={a.originalName}
className="max-w-[240px] max-h-64 object-contain rounded-xl block"
/>
</a> </a>
))} ))}
</div> </div>
@@ -115,10 +121,10 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
)} )}
</div> </div>
{/* Actions */} {/* Actions — visible only for active message */}
<div className={`opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 ${isMine ? 'flex-row-reverse' : ''}`}> <div className={`transition-opacity flex items-center gap-1 ${showActions ? 'opacity-100' : 'opacity-0 pointer-events-none'} ${isMine ? 'flex-row-reverse' : ''}`}>
<button <button
onClick={() => onReply(message)} onClick={(e) => { e.stopPropagation(); onReply(message); }}
className="p-1 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors" className="p-1 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors"
title="Ответить" title="Ответить"
> >
@@ -126,7 +132,7 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
</button> </button>
{isMine && message.type === 'text' && ( {isMine && message.type === 'text' && (
<button <button
onClick={() => onEdit(message)} onClick={(e) => { e.stopPropagation(); onEdit(message); }}
className="p-1 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors" className="p-1 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors"
title="Редактировать" title="Редактировать"
> >
@@ -135,7 +141,7 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
)} )}
{isMine && ( {isMine && (
<button <button
onClick={() => onDelete(message.id)} onClick={(e) => { e.stopPropagation(); onDelete(message.id); }}
className="p-1 rounded-lg hover:bg-red-100 text-gray-400 hover:text-red-500 transition-colors" className="p-1 rounded-lg hover:bg-red-100 text-gray-400 hover:text-red-500 transition-colors"
title="Удалить" title="Удалить"
> >

View File

@@ -36,6 +36,7 @@ export default function MessageList({ chatId, isGroup, canSend }: Props) {
const [replyTo, setReplyTo] = useState<Message | null>(null); const [replyTo, setReplyTo] = useState<Message | null>(null);
const [editMsg, setEditMsg] = useState<Message | null>(null); const [editMsg, setEditMsg] = useState<Message | null>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false); const [showScrollBtn, setShowScrollBtn] = useState(false);
const [activeMenuMsgId, setActiveMenuMsgId] = useState<string | null>(null);
const isAtBottom = useRef(true); const isAtBottom = useRef(true);
useEffect(() => { useEffect(() => {
@@ -128,6 +129,8 @@ export default function MessageList({ chatId, isGroup, canSend }: Props) {
showAvatar={showAvatar} showAvatar={showAvatar}
isGroup={isGroup} isGroup={isGroup}
isRead={!!(chatReadAt[chatId] && msg.createdAt <= chatReadAt[chatId])} isRead={!!(chatReadAt[chatId] && msg.createdAt <= chatReadAt[chatId])}
showActions={activeMenuMsgId === msg.id}
onShowActions={() => setActiveMenuMsgId(msg.id)}
onReply={handleReply} onReply={handleReply}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={handleEdit} onEdit={handleEdit}
@@ -141,6 +144,7 @@ export default function MessageList({ chatId, isGroup, canSend }: Props) {
<div <div
ref={containerRef} ref={containerRef}
onScroll={handleScroll} onScroll={handleScroll}
onClick={() => setActiveMenuMsgId(null)}
className="flex-1 overflow-y-auto px-4 py-3 space-y-0.5" className="flex-1 overflow-y-auto px-4 py-3 space-y-0.5"
> >
{loading && msgs.length === 0 && ( {loading && msgs.length === 0 && (