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 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}</> : <Navigate to="/login" replace />;
@@ -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) => {

View File

@@ -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 (
<div className={`flex items-end gap-2 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}>
{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>
@@ -37,8 +36,8 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
return (
<div
className={`flex items-end gap-2 mb-1 group ${isMine ? 'flex-row-reverse' : ''}`}
onMouseLeave={() => 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 */}
<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 */}
{!isMine && isGroup && showAvatar && message.sender && (
<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 */}
{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="text-gray-600 truncate">{message.replyTo.content}</div>
</div>
@@ -66,10 +68,14 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
{/* Images */}
{imageAttachments.length > 0 && (
<div className="mb-1 rounded-xl overflow-hidden">
<div className="mb-1">
{imageAttachments.map(a => (
<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>
))}
</div>
@@ -115,10 +121,10 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
)}
</div>
{/* Actions */}
<div className={`opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 ${isMine ? 'flex-row-reverse' : ''}`}>
{/* Actions — visible only for active message */}
<div className={`transition-opacity flex items-center gap-1 ${showActions ? 'opacity-100' : 'opacity-0 pointer-events-none'} ${isMine ? 'flex-row-reverse' : ''}`}>
<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"
title="Ответить"
>
@@ -126,7 +132,7 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
</button>
{isMine && message.type === 'text' && (
<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"
title="Редактировать"
>
@@ -135,7 +141,7 @@ export default function MessageItem({ message, isMine, showAvatar, isGroup, isRe
)}
{isMine && (
<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"
title="Удалить"
>

View File

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