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:
@@ -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) => {
|
||||
|
||||
@@ -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="Удалить"
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user