diff --git a/backend/src/db.ts b/backend/src/db.ts index 8bedd01..d5b7770 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -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 diff --git a/backend/src/routes/chats.ts b/backend/src/routes/chats.ts index 36eebcb..b031eb0 100644 --- a/backend/src/routes/chats.ts +++ b/backend/src/routes/chats.ts @@ -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 }; diff --git a/backend/src/ws.ts b/backend/src/ws.ts index c2d3467..73999e2 100644 --- a/backend/src/ws.ts +++ b/backend/src/ws.ts @@ -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 diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 76546fc..ff5bd62 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -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} участников`; } diff --git a/frontend/src/components/ChatListItem.tsx b/frontend/src/components/ChatListItem.tsx index 97f01c2..a007d43 100644 --- a/frontend/src/components/ChatListItem.tsx +++ b/frontend/src/components/ChatListItem.tsx @@ -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 ( - + {chat.type !== 'private' && ( + + )} + + + {/* Main row */} + +
+
+ + {icon && {icon}} + {chat.title} + + {time && {time}} +
+
+

+ {chat.lastMessage || (chat.type === 'channel' ? 'Канал' : 'Нет сообщений')} +

+
+ {chat.isMuted && } + {chat.unreadCount > 0 && ( + + {chat.unreadCount > 99 ? '99+' : chat.unreadCount} + + )} +
+
+
+ + ); } diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 2f0be15..777e122 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -163,13 +163,16 @@ export default function MessageInput({ chatId, replyTo, editMsg, onCancelReply, {showEmoji && ( -
- {EMOJIS.map(e => ( - - ))} -
+ <> +
setShowEmoji(false)} /> +
+ {EMOJIS.map(e => ( + + ))} +
+ )}
diff --git a/frontend/src/pages/MainLayout.tsx b/frontend/src/pages/MainLayout.tsx index 9d2c595..1caae51 100644 --- a/frontend/src/pages/MainLayout.tsx +++ b/frontend/src/pages/MainLayout.tsx @@ -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} /> ))} diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 57fbbcd..218c62e 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -78,6 +78,8 @@ export const useStore = create((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(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ce43bd8..4fb10fc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -44,6 +44,8 @@ export interface Chat { myRole?: string; canSendMessages?: boolean; canAddMembers?: boolean; + isPinned?: boolean; + isMuted?: boolean; } export interface Attachment {