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