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:
@@ -91,6 +91,7 @@ export async function initDB() {
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT NULL;
|
||||
ALTER TABLE chats ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS position TEXT DEFAULT NULL;
|
||||
ALTER TABLE chat_members ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE;
|
||||
`);
|
||||
|
||||
// Seed admin if no users
|
||||
|
||||
@@ -16,7 +16,7 @@ export default async function chatRoutes(app: FastifyInstance) {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
c.id, c.type, c.title, c.description, c.avatar_color, c.avatar, c.is_public, c.created_at,
|
||||
cm.role, cm.last_read_at,
|
||||
cm.role, cm.last_read_at, cm.is_pinned, cm.is_muted,
|
||||
(SELECT content FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message_at,
|
||||
(SELECT COUNT(*) FROM messages WHERE chat_id = c.id AND is_deleted = FALSE AND created_at > cm.last_read_at) AS unread_count,
|
||||
@@ -44,7 +44,7 @@ export default async function chatRoutes(app: FastifyInstance) {
|
||||
) END AS private_user_id
|
||||
FROM chats c
|
||||
JOIN chat_members cm ON cm.chat_id = c.id AND cm.user_id = $1
|
||||
ORDER BY COALESCE(
|
||||
ORDER BY cm.is_pinned DESC, COALESCE(
|
||||
(SELECT created_at FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1),
|
||||
c.created_at
|
||||
) DESC
|
||||
@@ -65,6 +65,8 @@ export default async function chatRoutes(app: FastifyInstance) {
|
||||
memberCount: parseInt(r.member_count),
|
||||
privateUserId: r.private_user_id,
|
||||
createdAt: r.created_at,
|
||||
isPinned: r.is_pinned,
|
||||
isMuted: r.is_muted,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -308,6 +310,24 @@ export default async function chatRoutes(app: FastifyInstance) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Pin/unpin chat for current user
|
||||
app.put('/:id/pin', async (req) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { id } = req.params as { id: string };
|
||||
const { pinned } = req.body as { pinned: boolean };
|
||||
await pool.query('UPDATE chat_members SET is_pinned = $1 WHERE chat_id = $2 AND user_id = $3', [pinned, id, userId]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Mute/unmute chat for current user
|
||||
app.put('/:id/mute', async (req) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { id } = req.params as { id: string };
|
||||
const { muted } = req.body as { muted: boolean };
|
||||
await pool.query('UPDATE chat_members SET is_muted = $1 WHERE chat_id = $2 AND user_id = $3', [muted, id, userId]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Leave chat
|
||||
app.delete('/:id/leave', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
|
||||
@@ -79,7 +79,6 @@ export function setupWebSocket(app: FastifyInstance) {
|
||||
ws.on('message', async (raw: any, isBinary: boolean) => {
|
||||
try {
|
||||
const str = isBinary ? raw.toString('utf8') : raw.toString();
|
||||
console.log('[WS] received:', str.substring(0, 200));
|
||||
const { type, payload } = JSON.parse(str);
|
||||
|
||||
if (type === 'typing') {
|
||||
@@ -134,7 +133,7 @@ export function setupWebSocket(app: FastifyInstance) {
|
||||
const members = await getChatMemberIds(payload.chatId);
|
||||
broadcast(members, { type: 'new_message', payload: fullMsg });
|
||||
|
||||
await sendPushToOfflineUsers(members, {
|
||||
await sendPushToOfflineUsers(members.filter(id => id !== uid), {
|
||||
title: sender.display_name,
|
||||
body: payload.content.substring(0, 100),
|
||||
chatId: payload.chatId
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ArrowLeft, MoreVertical, Users, Info, LogOut, Trash2, Settings } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { Chat } from '../types';
|
||||
import Avatar from './Avatar';
|
||||
import { useStore } from '../store';
|
||||
@@ -21,11 +23,21 @@ export default function ChatHeader({ chat, onBack, onRefresh, onShowInfo }: Prop
|
||||
const isPrivate = chat.type === 'private';
|
||||
const isOnline = !!(isPrivate && chat.privateUserId && onlineUsers.has(chat.privateUserId));
|
||||
|
||||
const otherMember = isPrivate && chat.members
|
||||
? chat.members.find(m => m.id !== user?.id)
|
||||
: null;
|
||||
|
||||
let subtitle = '';
|
||||
if (typing.length > 0) {
|
||||
subtitle = 'печатает...';
|
||||
} else if (isPrivate) {
|
||||
subtitle = isOnline ? 'в сети' : 'не в сети';
|
||||
if (isOnline) {
|
||||
subtitle = 'в сети';
|
||||
} else if (otherMember?.lastSeen) {
|
||||
subtitle = 'был(а) в сети ' + formatDistanceToNow(new Date(otherMember.lastSeen), { addSuffix: true, locale: ru });
|
||||
} else {
|
||||
subtitle = 'не в сети';
|
||||
}
|
||||
} else {
|
||||
subtitle = `${chat.memberCount} участников`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,13 +163,16 @@ export default function MessageInput({ chatId, replyTo, editMsg, onCancelReply,
|
||||
<Smile className="w-5 h-5" />
|
||||
</button>
|
||||
{showEmoji && (
|
||||
<div className="absolute bottom-10 left-0 bg-white rounded-2xl shadow-xl p-3 grid grid-cols-5 gap-1 z-10 border border-gray-100">
|
||||
{EMOJIS.map(e => (
|
||||
<button key={e} onClick={() => insertEmoji(e)} className="text-xl hover:bg-gray-100 rounded-lg p-1">
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowEmoji(false)} />
|
||||
<div className="absolute bottom-full mb-2 left-0 bg-white rounded-2xl shadow-xl p-3 grid grid-cols-5 gap-1 z-50 border border-gray-100">
|
||||
{EMOJIS.map(e => (
|
||||
<button key={e} onClick={() => insertEmoji(e)} className="text-xl hover:bg-gray-100 rounded-lg p-1">
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,12 +50,11 @@ export default function MainLayout() {
|
||||
}, [chats]);
|
||||
|
||||
async function openChat(chat: Chat) {
|
||||
// Load full chat info
|
||||
try {
|
||||
const { data } = await api.get(`/api/chats/${chat.id}`);
|
||||
const merged = { ...chat, ...data };
|
||||
setActiveChat(merged);
|
||||
updateChat({ id: chat.id, unreadCount: 0 });
|
||||
updateChat({ id: chat.id, unreadCount: 0, title: merged.title, avatarColor: merged.avatarColor, avatar: merged.avatar ?? null });
|
||||
wsClient.send('read_messages', { chatId: chat.id });
|
||||
} catch {
|
||||
setActiveChat({ ...chat, myRole: chat.myRole || chat.role });
|
||||
@@ -64,6 +63,29 @@ export default function MainLayout() {
|
||||
setShowInfo(false);
|
||||
}
|
||||
|
||||
async function handlePin(chatId: string, pinned: boolean) {
|
||||
try {
|
||||
await api.put(`/api/chats/${chatId}/pin`, { pinned });
|
||||
updateChat({ id: chatId, isPinned: pinned });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleMute(chatId: string, muted: boolean) {
|
||||
try {
|
||||
await api.put(`/api/chats/${chatId}/mute`, { muted });
|
||||
updateChat({ id: chatId, isMuted: muted });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleLeaveChat(chatId: string) {
|
||||
if (!confirm('Выйти из чата?')) return;
|
||||
try {
|
||||
await api.delete(`/api/chats/${chatId}/leave`);
|
||||
if (activeChat?.id === chatId) { setActiveChat(null); setMobileChatOpen(false); }
|
||||
setChats(chats.filter(c => c.id !== chatId));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Global search: fetch users when query changes
|
||||
useEffect(() => {
|
||||
if (search.trim().length < 1) { setSearchUsers([]); return; }
|
||||
@@ -170,6 +192,9 @@ export default function MainLayout() {
|
||||
active={activeChat?.id === chat.id}
|
||||
online={chat.type === 'private' && chat.privateUserId ? onlineUsers.has(chat.privateUserId) : undefined}
|
||||
onClick={() => { openChat(chat); setSearch(''); }}
|
||||
onPin={handlePin}
|
||||
onMute={handleMute}
|
||||
onLeave={handleLeaveChat}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export const useStore = create<AppStore>((set, get) => ({
|
||||
unreadCount: state.activeChat?.id === message.chatId ? 0 : c.unreadCount + 1 }
|
||||
: c
|
||||
).sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
const ta = a.lastMessageAt || a.createdAt;
|
||||
const tb = b.lastMessageAt || b.createdAt;
|
||||
return new Date(tb).getTime() - new Date(ta).getTime();
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface Chat {
|
||||
myRole?: string;
|
||||
canSendMessages?: boolean;
|
||||
canAddMembers?: boolean;
|
||||
isPinned?: boolean;
|
||||
isMuted?: boolean;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
|
||||
Reference in New Issue
Block a user