fix: message sending fallback + iOS zoom on input + openOrCreatePrivate
- MessageInput: HTTP fallback when WebSocket not OPEN (prevents silent failures) - WsClient: add trySend() returning bool to detect WS availability - Backend: POST /api/messages/chat/:chatId endpoint for HTTP message send - openOrCreatePrivate: use openChat() for consistency after refreshing chat list - iOS fix: inputs get font-size:16px on mobile to prevent Safari auto-zoom Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
// Upload file and send as message
|
||||||
app.post('/chat/:chatId/upload', async (req, reply) => {
|
app.post('/chat/:chatId/upload', async (req, reply) => {
|
||||||
const { id: userId } = req.user as { id: string };
|
const { id: userId } = req.user as { id: string };
|
||||||
|
|||||||
@@ -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() {
|
disconnect() {
|
||||||
this.token = null;
|
this.token = null;
|
||||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||||
|
|||||||
@@ -77,20 +77,29 @@ export default function MessageInput({ chatId, replyTo, editMsg, onCancelReply,
|
|||||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||||
wsClient.send('typing', { chatId, typing: false });
|
wsClient.send('typing', { chatId, typing: false });
|
||||||
|
|
||||||
if (editMsg) {
|
try {
|
||||||
wsClient.send('edit_message', { messageId: editMsg.id, content });
|
if (editMsg) {
|
||||||
onEditDone({ ...editMsg, content, isEdited: true });
|
wsClient.send('edit_message', { messageId: editMsg.id, content });
|
||||||
} else {
|
onEditDone({ ...editMsg, content, isEdited: true });
|
||||||
wsClient.send('send_message', {
|
} else {
|
||||||
chatId,
|
const sent = wsClient.trySend('send_message', {
|
||||||
content,
|
chatId, content, type: 'text', replyToId: replyTo?.id || null,
|
||||||
type: 'text',
|
});
|
||||||
replyToId: replyTo?.id || null,
|
if (!sent) {
|
||||||
});
|
// WS not available — use HTTP fallback
|
||||||
onCancelReply();
|
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<HTMLInputElement>) {
|
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
|||||||
@@ -35,3 +35,10 @@ body {
|
|||||||
/* Safe areas for mobile */
|
/* Safe areas for mobile */
|
||||||
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
|
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
|
||||||
.pt-safe { padding-top: env(safe-area-inset-top); }
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,16 +90,19 @@ export default function MainLayout() {
|
|||||||
try {
|
try {
|
||||||
const { data: chatData } = await api.post(`/api/chats/private/${userId}`);
|
const { data: chatData } = await api.post(`/api/chats/private/${userId}`);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
// Load full chat detail directly by ID — don't rely on list lookup
|
const { data: allChats } = await api.get('/api/chats');
|
||||||
const { data: fullChatData } = await api.get(`/api/chats/${chatData.id}`);
|
setChats(allChats);
|
||||||
// Also refresh the sidebar chat list
|
const chatFromList = allChats.find((c: any) => c.id === chatData.id);
|
||||||
api.get('/api/chats').then(r => setChats(r.data)).catch(() => {});
|
if (chatFromList) {
|
||||||
// Build chat object to open
|
await openChat(chatFromList);
|
||||||
const chatToOpen = { ...fullChatData, id: chatData.id };
|
} else {
|
||||||
setActiveChat(chatToOpen);
|
// fallback: open by direct API call
|
||||||
setFullChat(chatToOpen);
|
const { data: fullChatData } = await api.get(`/api/chats/${chatData.id}`);
|
||||||
setMobileChatOpen(true);
|
setActiveChat(fullChatData);
|
||||||
setShowInfo(false);
|
setFullChat(fullChatData);
|
||||||
|
setMobileChatOpen(true);
|
||||||
|
setShowInfo(false);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('openOrCreatePrivate:', e);
|
console.error('openOrCreatePrivate:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user