feat: swipe gestures, pin/mute, last seen, emoji fix, push/rename fixes

- Add swipe-to-reveal in mobile chat list (right=pin, left=mute/leave)
- Pin/mute API endpoints with is_pinned DB column migration
- Show pin icon on pinned chats, mute icon on muted chats
- Pinned chats sort to top of list
- Fix push notifications sent to message sender (filter uid)
- Remove debug console.log from WS handler
- Fix chat rename not updating in sidebar without reload
- Fix emoji picker z-index and positioning on mobile (z-50, bottom-full)
- Add last seen time in chat header for private chats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ai
2026-05-23 17:08:18 +03:00
parent 20daa2ca2f
commit 9262ece939
9 changed files with 184 additions and 43 deletions

View File

@@ -1,5 +1,7 @@
import { useRef, useState } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
import { Pin, BellOff, Bell, LogOut } from 'lucide-react';
import Avatar from './Avatar';
import { Chat } from '../types';
@@ -8,46 +10,121 @@ interface Props {
active: boolean;
online?: boolean;
onClick: () => void;
onPin: (chatId: string, pinned: boolean) => void;
onMute: (chatId: string, muted: boolean) => void;
onLeave: (chatId: string) => void;
}
export default function ChatListItem({ chat, active, online, onClick }: Props) {
export default function ChatListItem({ chat, active, online, onClick, onPin, onMute, onLeave }: Props) {
const [swipeX, setSwipeX] = useState(0);
const [isSwiping, setIsSwiping] = useState(false);
const touchStart = useRef<{ x: number; y: number } | null>(null);
const time = chat.lastMessageAt
? formatDistanceToNow(new Date(chat.lastMessageAt), { addSuffix: false, locale: ru })
: '';
const icon = chat.type === 'channel' ? '📢' : chat.type === 'group' ? '👥' : null;
function handleTouchStart(e: React.TouchEvent) {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
setIsSwiping(false);
}
function handleTouchMove(e: React.TouchEvent) {
if (!touchStart.current) return;
const dx = e.touches[0].clientX - touchStart.current.x;
const dy = Math.abs(e.touches[0].clientY - touchStart.current.y);
if (dy > 15 && Math.abs(dx) < dy) { touchStart.current = null; return; }
if (Math.abs(dx) > 8) setIsSwiping(true);
const clamped = Math.max(-120, Math.min(80, dx));
setSwipeX(clamped);
}
function handleTouchEnd() {
if (swipeX > 60) {
onPin(chat.id, !chat.isPinned);
}
setSwipeX(0);
setIsSwiping(false);
touchStart.current = null;
}
const showPinHint = swipeX > 30;
const showLeftActions = swipeX < -40;
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors ${
active ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
<Avatar
name={chat.title}
color={chat.avatarColor}
online={chat.type === 'private' ? online : undefined}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900 text-sm truncate">
{icon && <span className="mr-1 text-xs">{icon}</span>}
{chat.title}
</span>
{time && <span className="text-xs text-gray-400 flex-shrink-0 ml-1">{time}</span>}
</div>
<div className="flex items-center justify-between mt-0.5">
<p className="text-xs text-gray-500 truncate">
{chat.lastMessage || (chat.type === 'channel' ? 'Канал' : 'Нет сообщений')}
</p>
{chat.unreadCount > 0 && (
<span className="bg-blue-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 flex-shrink-0 ml-1">
{chat.unreadCount > 99 ? '99+' : chat.unreadCount}
<div className="relative overflow-hidden rounded-xl">
{/* Pin action (swipe right) */}
<div className={`absolute inset-y-0 left-0 w-20 flex flex-col items-center justify-center bg-blue-500 rounded-l-xl transition-opacity ${showPinHint ? 'opacity-100' : 'opacity-0'}`}>
<Pin className="w-5 h-5 text-white" />
<span className="text-white text-[10px] mt-0.5">{chat.isPinned ? 'Открепить' : 'Закрепить'}</span>
</div>
{/* Left actions: mute + leave (swipe left) */}
<div className={`absolute inset-y-0 right-0 flex rounded-r-xl overflow-hidden transition-opacity ${showLeftActions ? 'opacity-100' : 'opacity-0'}`}>
<button
onTouchEnd={(e) => { e.stopPropagation(); onMute(chat.id, !chat.isMuted); setSwipeX(0); }}
className="w-16 h-full flex flex-col items-center justify-center bg-gray-500 text-white text-[10px] gap-0.5"
>
{chat.isMuted ? <Bell className="w-5 h-5" /> : <BellOff className="w-5 h-5" />}
<span>{chat.isMuted ? 'Вкл. звук' : 'Без звука'}</span>
</button>
{chat.type !== 'private' && (
<button
onTouchEnd={(e) => { e.stopPropagation(); onLeave(chat.id); setSwipeX(0); }}
className="w-16 h-full flex flex-col items-center justify-center bg-red-500 text-white text-[10px] gap-0.5"
>
<LogOut className="w-5 h-5" />
<span>Выйти</span>
</button>
)}
</div>
{/* Main row */}
<button
onClick={isSwiping ? undefined : onClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{ transform: `translateX(${swipeX}px)`, transition: swipeX === 0 ? 'transform 0.2s ease' : 'none' }}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left ${active ? 'bg-blue-50' : 'bg-white hover:bg-gray-50'}`}
>
<div className="relative flex-shrink-0">
<Avatar
name={chat.title}
color={chat.avatarColor}
online={chat.type === 'private' ? online : undefined}
/>
{chat.isPinned && (
<span className="absolute -bottom-0.5 -right-0.5 bg-blue-500 rounded-full p-0.5">
<Pin className="w-2 h-2 text-white" />
</span>
)}
</div>
</div>
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900 text-sm truncate">
{icon && <span className="mr-1 text-xs">{icon}</span>}
{chat.title}
</span>
{time && <span className="text-xs text-gray-400 flex-shrink-0 ml-1">{time}</span>}
</div>
<div className="flex items-center justify-between mt-0.5">
<p className="text-xs text-gray-500 truncate">
{chat.lastMessage || (chat.type === 'channel' ? 'Канал' : 'Нет сообщений')}
</p>
<div className="flex items-center gap-1 flex-shrink-0 ml-1">
{chat.isMuted && <BellOff className="w-3 h-3 text-gray-400" />}
{chat.unreadCount > 0 && (
<span className={`${chat.isMuted ? 'bg-gray-400' : 'bg-blue-500'} text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1`}>
{chat.unreadCount > 99 ? '99+' : chat.unreadCount}
</span>
)}
</div>
</div>
</div>
</button>
</div>
);
}