diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 1b36fb1..e7c5900 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -86,6 +86,55 @@ export default async function messageRoutes(app: FastifyInstance) { })); }); + // Send text message via HTTP (fallback when WS unavailable) + app.post('/chat/:chatId', async (req, reply) => { + const { id: userId } = req.user as { id: string }; + const { chatId } = req.params as { chatId: string }; + const { content, type = 'text', replyToId } = req.body as any; + + if (!content?.trim()) return reply.status(400).send({ error: 'Empty message' }); + + const { rows: [member] } = await pool.query( + 'SELECT role, can_send_messages FROM chat_members WHERE chat_id = $1 AND user_id = $2', + [chatId, userId] + ); + if (!member) return reply.status(403).send({ error: 'Not a member' }); + if (!member.can_send_messages && member.role === 'member') return reply.status(403).send({ error: 'No permission' }); + + const { rows: [chat] } = await pool.query('SELECT type FROM chats WHERE id = $1', [chatId]); + if (chat.type === 'channel' && !['owner', 'admin'].includes(member.role)) return reply.status(403).send({ error: 'No permission' }); + + const { rows: [msg] } = await pool.query( + `INSERT INTO messages (chat_id, sender_id, content, type, reply_to_id) + VALUES ($1,$2,$3,$4,$5) RETURNING *`, + [chatId, userId, content.trim(), type, replyToId || null] + ); + + const { rows: [sender] } = await pool.query( + 'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [userId] + ); + + const fullMsg = { + id: msg.id, chatId: msg.chat_id, content: msg.content, type: msg.type, + isDeleted: false, isEdited: false, createdAt: msg.created_at, + replyTo: null, attachments: [], + sender: { id: sender.id, username: sender.username, displayName: sender.display_name, avatarColor: sender.avatar_color } + }; + + // Broadcast via WS to all members + const { connections } = await import('../ws.js'); + const { rows: members } = await pool.query('SELECT user_id FROM chat_members WHERE chat_id = $1', [chatId]); + const wsMsg = JSON.stringify({ type: 'new_message', payload: fullMsg }); + for (const m of members) { + const sockets = connections.get(m.user_id); + if (sockets) sockets.forEach((ws: any) => { try { if (ws.readyState === 1) ws.send(wsMsg); } catch {} }); + } + + await pool.query('UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2', [chatId, userId]); + + return reply.status(201).send(fullMsg); + }); + // Upload file and send as message app.post('/chat/:chatId/upload', async (req, reply) => { const { id: userId } = req.user as { id: string }; diff --git a/frontend/src/api/ws.ts b/frontend/src/api/ws.ts index a6d0cab..e32ce72 100644 --- a/frontend/src/api/ws.ts +++ b/frontend/src/api/ws.ts @@ -59,6 +59,14 @@ class WsClient { } } + trySend(type: string, payload: any): boolean { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type, payload })); + return true; + } + return false; + } + disconnect() { this.token = null; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index e8a227b..2f0be15 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -77,20 +77,29 @@ export default function MessageInput({ chatId, replyTo, editMsg, onCancelReply, if (textareaRef.current) textareaRef.current.style.height = 'auto'; wsClient.send('typing', { chatId, typing: false }); - if (editMsg) { - wsClient.send('edit_message', { messageId: editMsg.id, content }); - onEditDone({ ...editMsg, content, isEdited: true }); - } else { - wsClient.send('send_message', { - chatId, - content, - type: 'text', - replyToId: replyTo?.id || null, - }); - onCancelReply(); + try { + if (editMsg) { + wsClient.send('edit_message', { messageId: editMsg.id, content }); + onEditDone({ ...editMsg, content, isEdited: true }); + } else { + const sent = wsClient.trySend('send_message', { + chatId, content, type: 'text', replyToId: replyTo?.id || null, + }); + if (!sent) { + // WS not available — use HTTP fallback + const { data } = await api.post(`/api/messages/chat/${chatId}`, { + content, type: 'text', replyToId: replyTo?.id || null, + }); + addMessage(data); + } + onCancelReply(); + } + } catch { + // restore text so user doesn't lose it + setText(content); + } finally { + setSending(false); } - - setSending(false); } async function handleFile(e: React.ChangeEvent) { diff --git a/frontend/src/index.css b/frontend/src/index.css index 186dd76..9428b18 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -35,3 +35,10 @@ body { /* Safe areas for mobile */ .pb-safe { padding-bottom: env(safe-area-inset-bottom); } .pt-safe { padding-top: env(safe-area-inset-top); } + +/* Prevent iOS auto-zoom on input focus (triggered when font-size < 16px) */ +@media screen and (max-width: 768px) { + input, textarea, select { + font-size: 16px !important; + } +} diff --git a/frontend/src/pages/MainLayout.tsx b/frontend/src/pages/MainLayout.tsx index 6169f3c..157a073 100644 --- a/frontend/src/pages/MainLayout.tsx +++ b/frontend/src/pages/MainLayout.tsx @@ -90,16 +90,19 @@ export default function MainLayout() { try { const { data: chatData } = await api.post(`/api/chats/private/${userId}`); setSearch(''); - // Load full chat detail directly by ID — don't rely on list lookup - const { data: fullChatData } = await api.get(`/api/chats/${chatData.id}`); - // Also refresh the sidebar chat list - api.get('/api/chats').then(r => setChats(r.data)).catch(() => {}); - // Build chat object to open - const chatToOpen = { ...fullChatData, id: chatData.id }; - setActiveChat(chatToOpen); - setFullChat(chatToOpen); - setMobileChatOpen(true); - setShowInfo(false); + const { data: allChats } = await api.get('/api/chats'); + setChats(allChats); + const chatFromList = allChats.find((c: any) => c.id === chatData.id); + if (chatFromList) { + await openChat(chatFromList); + } else { + // fallback: open by direct API call + const { data: fullChatData } = await api.get(`/api/chats/${chatData.id}`); + setActiveChat(fullChatData); + setFullChat(fullChatData); + setMobileChatOpen(true); + setShowInfo(false); + } } catch (e) { console.error('openOrCreatePrivate:', e); }