From 82b88eb75ca5efd367ee3596686b0dbf86fe228f Mon Sep 17 00:00:00 2001 From: sergioapi Date: Sun, 4 Jan 2026 13:11:17 +0100 Subject: [PATCH 01/12] feat: add messaging views, socket integration, and services --- src/App.jsx | 8 +- .../app/messages/ConversationThreadPage.jsx | 165 ++++++++++++++++++ src/pages/app/messages/ConversationsPage.jsx | 150 ++++++++++++++++ src/pages/app/messages/MessagingLayout.jsx | 14 ++ src/services/messagingService.js | 61 +++++++ src/services/messagingSocket.js | 62 +++++++ 6 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 src/pages/app/messages/ConversationThreadPage.jsx create mode 100644 src/pages/app/messages/ConversationsPage.jsx create mode 100644 src/pages/app/messages/MessagingLayout.jsx create mode 100644 src/services/messagingService.js create mode 100644 src/services/messagingSocket.js diff --git a/src/App.jsx b/src/App.jsx index 93281fc..f841575 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -31,6 +31,7 @@ import PlaylistDetails from './pages/app/beats-interaction/playlist/PlaylistDeta import ForgotPassword from './pages/auth/ForgotPassword'; import ResetPassword from './pages/auth/ResetPassword'; import VerifyEmail from './pages/auth/VerifyEmail'; +import MessagingLayout from './pages/app/messages/MessagingLayout'; function App() { const spaceClient = useSpaceClient(); @@ -79,10 +80,15 @@ function App() { } /> } /> } /> - } /> } /> } /> + {/* Rutas de Mensajería */} + }> + } /> + } /> + + {/* Rutas del microservicio Dashboards */} } /> } /> diff --git a/src/pages/app/messages/ConversationThreadPage.jsx b/src/pages/app/messages/ConversationThreadPage.jsx new file mode 100644 index 0000000..18072b9 --- /dev/null +++ b/src/pages/app/messages/ConversationThreadPage.jsx @@ -0,0 +1,165 @@ +// src/pages/app/messages/ConversationThreadPage.jsx +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { listMessages, sendMessage } from '@/services/messagingService'; +import { connectMessagingSocket, onMessageNew, offMessageNew, disconnectMessagingSocket } from '@/services/messagingSocket'; + +function normalizeId(x) { + return String(x || ''); +} + +export default function ConversationThreadPage() { + const { id: conversationId } = useParams(); + + const [items, setItems] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [before, setBefore] = useState(null); + + const [status, setStatus] = useState('idle'); // idle | loading | error + const [error, setError] = useState(null); + + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + + const knownMessageIdsRef = useRef(new Set()); // para evitar duplicados con message:new + POST response + + async function loadFirstPage() { + setStatus('loading'); + setError(null); + try { + const data = await listMessages(conversationId, { limit: 30 }); + const msgs = data.items || []; + setItems(msgs); + setHasMore(Boolean(data.hasMore)); + setBefore(data.nextCursor || null); + + knownMessageIdsRef.current = new Set(msgs.map((m) => normalizeId(m._id))); + + setStatus('idle'); + } catch (e) { + setStatus('error'); + setError(e); + } + } + + async function loadOlder() { + if (!hasMore || !before) return; + setStatus('loading'); + setError(null); + + try { + const data = await listMessages(conversationId, { before, limit: 30 }); + const older = data.items || []; + + setItems((prev) => { + // older viene en cronológico; lo insertamos al principio + const merged = [...older, ...prev]; + // actualizar set de ids + for (const m of older) knownMessageIdsRef.current.add(normalizeId(m._id)); + return merged; + }); + + setHasMore(Boolean(data.hasMore)); + setBefore(data.nextCursor || null); + + setStatus('idle'); + } catch (e) { + setStatus('error'); + setError(e); + } + } + + async function onSubmit(e) { + e.preventDefault(); + const trimmed = text.trim(); + if (!trimmed) return; + + setSending(true); + try { + const res = await sendMessage(conversationId, { text: trimmed }); + // No optimista: añadimos si no llega por socket o para asegurar. + const msg = res?.message; + if (msg && !knownMessageIdsRef.current.has(normalizeId(msg._id))) { + knownMessageIdsRef.current.add(normalizeId(msg._id)); + setItems((prev) => [...prev, msg]); + } + setText(''); + } finally { + setSending(false); + } + } + + useEffect(() => { + if (!conversationId) return; + loadFirstPage(); + }, [conversationId]); + + useEffect(() => { + + const handler = ({ conversationId: incomingConvoId, message }) => { + if (normalizeId(incomingConvoId) !== normalizeId(conversationId)) return; + if (!message) return; + + const mid = normalizeId(message._id); + if (knownMessageIdsRef.current.has(mid)) return; + + knownMessageIdsRef.current.add(mid); + setItems((prev) => [...prev, message]); + + console.log("[WS] message:new received", incomingConvoId, message?._id); + }; + + onMessageNew(handler); + return () => { + offMessageNew(handler); + }; + }, [conversationId]); + + return ( +
+

Conversación

+
{conversationId}
+ + {status === 'error' && ( +
+

No se pudieron cargar los mensajes.

+ +
+ )} + +
+ {hasMore && ( + + )} +
+ +
+ {items.map((m) => ( +
+
{String(m.senderId)}
+
{m.text}
+
+ {m.createdAt ? new Date(m.createdAt).toLocaleString() : ''} +
+
+ ))} +
+ +
+ setText(e.target.value)} + placeholder="Escribe un mensaje…" + style={{ flex: 1, padding: 8 }} + maxLength={1000} + disabled={sending} + /> + +
+
+ ); +} diff --git a/src/pages/app/messages/ConversationsPage.jsx b/src/pages/app/messages/ConversationsPage.jsx new file mode 100644 index 0000000..8124c24 --- /dev/null +++ b/src/pages/app/messages/ConversationsPage.jsx @@ -0,0 +1,150 @@ +// src/pages/app/messages/ConversationsPage.jsx +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { listConversations } from '@/services/messagingService'; +import { connectMessagingSocket, onMessageNew, offMessageNew, disconnectMessagingSocket } from '@/services/messagingSocket'; + +function formatDate(isoOrDate) { + if (!isoOrDate) return ''; + const d = new Date(isoOrDate); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleString(); +} + +export default function ConversationsPage() { + const [items, setItems] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [cursor, setCursor] = useState(null); + + const [status, setStatus] = useState('idle'); // idle | loading | error + const [error, setError] = useState(null); + + const [query, setQuery] = useState(''); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return items; + // V1: solo podemos filtrar por otherUserId/lastMessageText, porque no tenemos perfiles todavía. + return items.filter((c) => { + const other = String(c.otherUserId || '').toLowerCase(); + const last = String(c.lastMessageText || '').toLowerCase(); + return other.includes(q) || last.includes(q); + }); + }, [items, query]); + + async function loadFirstPage() { + setStatus('loading'); + setError(null); + try { + const data = await listConversations({ limit: 20 }); + setItems(data.items || []); + setHasMore(Boolean(data.hasMore)); + setCursor(data.nextCursor || null); + setStatus('idle'); + } catch (e) { + setStatus('error'); + setError(e); + } + } + + async function loadMore() { + if (!hasMore || !cursor) return; + setStatus('loading'); + setError(null); + try { + const data = await listConversations({ cursor, limit: 20 }); + setItems((prev) => [...prev, ...(data.items || [])]); + setHasMore(Boolean(data.hasMore)); + setCursor(data.nextCursor || null); + setStatus('idle'); + } catch (e) { + setStatus('error'); + setError(e); + } + } + + useEffect(() => { + loadFirstPage(); + }, []); + + useEffect(() => { + const handler = ({ conversationId, message }) => { + if (!conversationId || !message) return; + + setItems((prev) => { + // Actualiza lastMessage* y mueve arriba si ya existe en inbox. + const idx = prev.findIndex((c) => String(c._id) === String(conversationId)); + if (idx === -1) { + // V1: si no está, no inventamos un objeto; pedimos refetch. + // Para no spamear, podrías debouncear; en V1 hacemos simple: + loadFirstPage(); + return prev; + } + + const updated = { ...prev[idx] }; + updated.lastMessageAt = message.createdAt; + updated.lastMessageText = String(message.text || '').slice(0, 200); + + const without = prev.filter((_, i) => i !== idx); + return [updated, ...without]; + }); + console.log("[WS] inbox event", conversationId, message?._id); + }; + + onMessageNew(handler); + return () => { + offMessageNew(handler); + }; + }, []); + + return ( +
+

Mensajes

+ +
+ setQuery(e.target.value)} + placeholder="Buscar por usuario (ID) o texto…" + style={{ width: '100%', padding: 8 }} + /> +
+ + {status === 'error' && ( +
+

No se pudieron cargar las conversaciones.

+ +
+ )} + + {status !== 'error' && filtered.length === 0 && status !== 'loading' && ( +

No tienes conversaciones todavía.

+ )} + +
    + {filtered.map((c) => ( +
  • + {/* V1: sin perfiles, mostramos otherUserId como identificador */} +
    + {c.otherUserId ? `Usuario ${c.otherUserId}` : 'Conversación'} +
    +
    {c.lastMessageText || ''}
    +
    {formatDate(c.lastMessageAt)}
    + +
    + Abrir +
    +
  • + ))} +
+ +
+ {hasMore && ( + + )} +
+
+ ); +} diff --git a/src/pages/app/messages/MessagingLayout.jsx b/src/pages/app/messages/MessagingLayout.jsx new file mode 100644 index 0000000..8237bac --- /dev/null +++ b/src/pages/app/messages/MessagingLayout.jsx @@ -0,0 +1,14 @@ +import { Outlet } from "react-router-dom"; +import { useEffect } from "react"; +import { connectMessagingSocket, disconnectMessagingSocket } from "@/services/messagingSocket"; + +export default function MessagingLayout() { + useEffect(() => { + connectMessagingSocket(); + return () => { + disconnectMessagingSocket(); + }; + }, []); + + return ; +} \ No newline at end of file diff --git a/src/services/messagingService.js b/src/services/messagingService.js new file mode 100644 index 0000000..004adf4 --- /dev/null +++ b/src/services/messagingService.js @@ -0,0 +1,61 @@ +// src/services/messagingService.js +import { client } from '../api/axiosClient'; + +function withDevUserIdHeader(config = {}) { + // Si en runtime definís un usuario dev, lo enviamos. + // Si no existe, no inventamos nada y no enviamos el header. + const devUserId = + window.RUNTIME_CONFIG?.VITE_DEV_USER_ID || import.meta.env?.VITE_DEV_USER_ID; + + if (!devUserId) return config; + + return { + ...config, + headers: { + ...(config.headers || {}), + 'x-user-id': devUserId, + }, + }; +} + +export async function listConversations({ cursor = null, limit = 20 } = {}) { + const params = {}; + if (cursor) params.cursor = cursor; + if (limit) params.limit = limit; + + const res = await client.get( + '/social/conversations', + withDevUserIdHeader({ params }) + ); + return res.data; // { items, hasMore, nextCursor } +} + +export async function upsertDirectConversation({ otherUserId }) { + const res = await client.post( + '/social/conversations/direct', + { otherUserId }, + withDevUserIdHeader() + ); + return res.data; // { conversation } +} + +export async function listMessages(conversationId, { before = null, limit = 30 } = {}) { + const params = {}; + if (before) params.before = before; + if (limit) params.limit = limit; + + const res = await client.get( + `/social/conversations/${conversationId}/messages`, + withDevUserIdHeader({ params }) + ); + return res.data; // { items, hasMore, nextCursor } +} + +export async function sendMessage(conversationId, { text }) { + const res = await client.post( + `/social/conversations/${conversationId}/messages`, + { text }, + withDevUserIdHeader() + ); + return res.data; // { message } +} diff --git a/src/services/messagingSocket.js b/src/services/messagingSocket.js new file mode 100644 index 0000000..0c2fa19 --- /dev/null +++ b/src/services/messagingSocket.js @@ -0,0 +1,62 @@ +// src/services/messagingSocket.js +import { io } from 'socket.io-client'; +import { getCurrentUserId, getAccessToken } from '@/services/authService'; + +let socket = null; + +function getWsUrl() { + return ( + window.RUNTIME_CONFIG?.VITE_SOCIAL_WS_URL || + import.meta.env?.VITE_SOCIAL_WS_URL || + null + ); +} + +export function connectMessagingSocket() { + const wsUrl = getWsUrl(); + const userId = getCurrentUserId(); + const token = getAccessToken(); // opcional para futuro + + console.log("[socket] connectMessagingSocket called", { + wsUrl, + userId, + hasToken: !!token, + runtimeWs: window.RUNTIME_CONFIG?.VITE_SOCIAL_WS_URL, + envWs: import.meta.env?.VITE_SOCIAL_WS_URL, + }); + + + if (!wsUrl || !userId) return null; + if (socket?.connected) return socket; + + socket = io(wsUrl, { + // Nota: por ahora NO fuerces websocket; deja que Socket.IO negocie. + // transports: ['websocket'], + auth: { userId, token }, + autoConnect: true, + reconnection: true, + }); + + // Logs de diagnóstico (muy recomendables mientras estabilizas) + socket.on('connect', () => console.log('[socket] connected', socket.id)); + socket.on('disconnect', (reason) => console.log('[socket] disconnected', reason)); + socket.on('connect_error', (err) => console.error('[socket] connect_error', err?.message || err)); + + return socket; +} + +export function disconnectMessagingSocket() { + if (!socket) return; + socket.disconnect(); + socket = null; +} + +export function onMessageNew(handler) { + if (!socket) return; + socket.on('message:new', handler); +} + +export function offMessageNew(handler) { + if (!socket) return; + socket.off('message:new', handler); +} From 50e5b9fa6c3f26a6de2939d878ca10e157f9352a Mon Sep 17 00:00:00 2001 From: sergioapi Date: Sun, 4 Jan 2026 20:16:52 +0100 Subject: [PATCH 02/12] fix: added missing imports --- src/App.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/App.jsx b/src/App.jsx index f841575..d8373c5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,6 +32,8 @@ import ForgotPassword from './pages/auth/ForgotPassword'; import ResetPassword from './pages/auth/ResetPassword'; import VerifyEmail from './pages/auth/VerifyEmail'; import MessagingLayout from './pages/app/messages/MessagingLayout'; +import ConversationsPage from './pages/app/messages/ConversationsPage.jsx'; +import ConversationThreadPage from './pages/app/messages/ConversationThreadPage.jsx'; function App() { const spaceClient = useSpaceClient(); From 4cc6cd441017a845a57f0225fda195b1e9247675 Mon Sep 17 00:00:00 2001 From: sergioapi Date: Sun, 4 Jan 2026 20:18:11 +0100 Subject: [PATCH 03/12] chore: install socket dependencies --- .env.example | 3 +- package.json | 1 + pnpm-lock.yaml | 291 ++++++++++++++++++++++++++++++++++++------------- 3 files changed, 221 insertions(+), 74 deletions(-) diff --git a/.env.example b/.env.example index 4566e61..2ac1636 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ VITE_BASE_URL="http://localhost:3000/api/v1" VITE_SPACE_URL="http://localhost:5403" VITE_SPACE_API_KEY="" VITE_LOG_LEVEL="info" -VITE_CDN_DOMAIN="s3-cdn-server-for-retrieving-files" \ No newline at end of file +VITE_CDN_DOMAIN="s3-cdn-server-for-retrieving-files" +VITE_SOCIAL_WS_URL="http://localhost:3004" \ No newline at end of file diff --git a/package.json b/package.json index 435fb1c..405a589 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^7.9.6", "recharts": "^3.5.0", + "socket.io": "^4.8.3", "space-react-client": "^0.3.0", "wavesurfer.js": "^7.11.1", "zustand": "^5.0.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83284e7..ce8600a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,44 +16,47 @@ importers: version: 1.9.2 lucide-react: specifier: ^0.554.0 - version: 0.554.0(react@19.2.0) + version: 0.554.0(react@18.3.1) music-metadata: specifier: ^11.10.3 version: 11.10.3 react: - specifier: ^19.1.1 - version: 19.2.0 + specifier: ^18.3.1 + version: 18.3.1 react-dom: - specifier: ^19.1.1 - version: 19.2.0(react@19.2.0) + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) react-router-dom: specifier: ^7.9.6 - version: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.9.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) recharts: specifier: ^3.5.0 - version: 3.5.0(@types/react@19.2.2)(eslint@9.39.1)(react-dom@19.2.0(react@19.2.0))(react-is@19.2.0)(react@19.2.0)(redux@5.0.1) + version: 3.5.0(@types/react@18.3.27)(eslint@9.39.1)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.0)(react@18.3.1)(redux@5.0.1) + socket.io: + specifier: ^4.8.3 + version: 4.8.3 space-react-client: specifier: ^0.3.0 - version: 0.3.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) wavesurfer.js: specifier: ^7.11.1 version: 7.12.1 zustand: specifier: ^5.0.9 - version: 5.0.9(@types/react@19.2.2)(immer@11.0.0)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) + version: 5.0.9(@types/react@18.3.27)(immer@11.0.0)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: '@eslint/js': specifier: ^9.36.0 version: 9.39.1 '@types/react': - specifier: ^19.1.16 - version: 19.2.2 + specifier: ^18.3.27 + version: 18.3.27 '@types/react-dom': - specifier: ^19.1.9 - version: 19.2.2(@types/react@19.2.2) + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react-swc': specifier: ^4.1.0 - version: 4.2.1(vite@7.2.2) + version: 4.2.1(vite@7.2.2(@types/node@25.0.3)) eslint: specifier: ^9.36.0 version: 9.39.1 @@ -68,7 +71,7 @@ importers: version: 16.5.0 vite: specifier: ^7.1.7 - version: 7.2.2 + version: 7.2.2(@types/node@25.0.3) packages: @@ -503,6 +506,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -536,13 +542,19 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/react-dom@19.2.2': - resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: - '@types/react': ^19.2.0 + '@types/react': ^18.0.0 - '@types/react@19.2.2': - resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + '@types/react@18.3.27': + resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -553,6 +565,10 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -582,6 +598,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -619,16 +639,24 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.0.2: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} @@ -704,6 +732,10 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} + engine.io@6.6.5: + resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} + engines: {node: '>=10.2.0'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -928,6 +960,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -959,6 +994,10 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lucide-react@0.554.0: resolution: {integrity: sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==} peerDependencies: @@ -998,6 +1037,14 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1044,10 +1091,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - react-dom@19.2.0: - resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: - react: ^19.2.0 + react: ^18.3.1 react-is@19.2.0: resolution: {integrity: sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==} @@ -1081,8 +1128,8 @@ packages: react-dom: optional: true - react@19.2.0: - resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} recharts@3.5.0: @@ -1113,8 +1160,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -1127,6 +1174,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + socket.io-adapter@2.5.6: + resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + socket.io-client@4.8.3: resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} engines: {node: '>=10.0.0'} @@ -1135,6 +1185,10 @@ packages: resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} engines: {node: '>=10.0.0'} + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1179,6 +1233,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1187,6 +1244,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} @@ -1421,7 +1482,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1))(react@19.2.0)': + '@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': dependencies: '@standard-schema/spec': 1.0.0 '@standard-schema/utils': 0.3.0 @@ -1430,8 +1491,8 @@ snapshots: redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.1.1 optionalDependencies: - react: 19.2.0 - react-redux: 9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1) + react: 18.3.1 + react-redux: 9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1) '@rolldown/pluginutils@1.0.0-beta.46': {} @@ -1568,6 +1629,10 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/cors@2.8.19': + dependencies: + '@types/node': 25.0.3 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -1596,24 +1661,36 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/react-dom@19.2.2(@types/react@19.2.2)': + '@types/node@25.0.3': dependencies: - '@types/react': 19.2.2 + undici-types: 7.16.0 - '@types/react@19.2.2': + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.27)': dependencies: - csstype: 3.1.3 + '@types/react': 18.3.27 + + '@types/react@18.3.27': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 '@types/use-sync-external-store@0.0.6': {} - '@vitejs/plugin-react-swc@4.2.1(vite@7.2.2)': + '@vitejs/plugin-react-swc@4.2.1(vite@7.2.2(@types/node@25.0.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.46 '@swc/core': 1.15.0 - vite: 7.2.2 + vite: 7.2.2(@types/node@25.0.3) transitivePeerDependencies: - '@swc/helpers' + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -1645,6 +1722,8 @@ snapshots: balanced-match@1.0.2: {} + base64id@2.0.0: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -1678,15 +1757,22 @@ snapshots: content-type@1.0.5: {} + cookie@0.7.2: {} + cookie@1.0.2: {} + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - csstype@3.1.3: {} + csstype@3.2.3: {} d3-array@3.2.4: dependencies: @@ -1756,6 +1842,22 @@ snapshots: engine.io-parser@5.2.3: {} + engine.io@6.6.5: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 25.0.3 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -1999,6 +2101,8 @@ snapshots: isexe@2.0.0: {} + js-tokens@4.0.0: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -2026,9 +2130,13 @@ snapshots: loglevel@1.9.2: {} - lucide-react@0.554.0(react@19.2.0): + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lucide-react@0.554.0(react@18.3.1): dependencies: - react: 19.2.0 + react: 18.3.1 math-intrinsics@1.1.0: {} @@ -2064,6 +2172,10 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + + object-assign@4.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2105,54 +2217,57 @@ snapshots: punycode@2.3.1: {} - react-dom@19.2.0(react@19.2.0): + react-dom@18.3.1(react@18.3.1): dependencies: - react: 19.2.0 - scheduler: 0.27.0 + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 react-is@19.2.0: {} - react-redux@9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1): + react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 - react: 19.2.0 - use-sync-external-store: 1.6.0(react@19.2.0) + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 18.3.27 redux: 5.0.1 - react-router-dom@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + react-router-dom@7.9.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - react-router: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 7.9.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + react-router@7.9.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: cookie: 1.0.2 - react: 19.2.0 + react: 18.3.1 set-cookie-parser: 2.7.2 optionalDependencies: - react-dom: 19.2.0(react@19.2.0) + react-dom: 18.3.1(react@18.3.1) - react@19.2.0: {} + react@18.3.1: + dependencies: + loose-envify: 1.4.0 - recharts@3.5.0(@types/react@19.2.2)(eslint@9.39.1)(react-dom@19.2.0(react@19.2.0))(react-is@19.2.0)(react@19.2.0)(redux@5.0.1): + recharts@3.5.0(@types/react@18.3.27)(eslint@9.39.1)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.0)(react@18.3.1)(redux@5.0.1): dependencies: - '@reduxjs/toolkit': 2.11.0(react-redux@9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1))(react@19.2.0) + '@reduxjs/toolkit': 2.11.0(react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1))(react@18.3.1) clsx: 2.1.1 decimal.js-light: 2.5.1 es-toolkit: 1.42.0 eslint-plugin-react-perf: 3.3.3(eslint@9.39.1) eventemitter3: 5.0.1 immer: 10.2.0 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) react-is: 19.2.0 - react-redux: 9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1) + react-redux: 9.2.0(@types/react@18.3.27)(react@18.3.1)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 - use-sync-external-store: 1.6.0(react@19.2.0) + use-sync-external-store: 1.6.0(react@18.3.1) victory-vendor: 37.3.6 transitivePeerDependencies: - '@types/react' @@ -2197,7 +2312,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.5 fsevents: 2.3.3 - scheduler@0.27.0: {} + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 set-cookie-parser@2.7.2: {} @@ -2207,6 +2324,15 @@ snapshots: shebang-regex@3.0.0: {} + socket.io-adapter@2.5.6: + dependencies: + debug: 4.4.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -2225,13 +2351,27 @@ snapshots: transitivePeerDependencies: - supports-color + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.4.3 + engine.io: 6.6.5 + socket.io-adapter: 2.5.6 + socket.io-parser: 4.2.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + source-map-js@1.2.1: {} - space-react-client@0.3.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + space-react-client@0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: axios: 1.13.2 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) socket.io-client: 4.8.3 tiny-emitter: 2.1.0 transitivePeerDependencies: @@ -2271,13 +2411,17 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@7.16.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.0): + use-sync-external-store@1.6.0(react@18.3.1): dependencies: - react: 19.2.0 + react: 18.3.1 + + vary@1.1.2: {} victory-vendor@37.3.6: dependencies: @@ -2296,7 +2440,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.2.2: + vite@7.2.2(@types/node@25.0.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -2305,6 +2449,7 @@ snapshots: rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.0.3 fsevents: 2.3.3 wavesurfer.js@7.12.1: {} @@ -2321,9 +2466,9 @@ snapshots: yocto-queue@0.1.0: {} - zustand@5.0.9(@types/react@19.2.2)(immer@11.0.0)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): + zustand@5.0.9(@types/react@18.3.27)(immer@11.0.0)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 18.3.27 immer: 11.0.0 - react: 19.2.0 - use-sync-external-store: 1.6.0(react@19.2.0) + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) From e4fcc2ae67aef940b4cd012c17f73820ea51d9d2 Mon Sep 17 00:00:00 2001 From: sergioapi Date: Mon, 5 Jan 2026 14:23:42 +0100 Subject: [PATCH 04/12] feat: enhanced conversations inbox and messages views and added ws-url into config file --- public/config.js | 3 +- .../app/messages/ConversationThreadPage.jsx | 747 +++++++++++++-- src/pages/app/messages/ConversationsPage.jsx | 865 ++++++++++++++++-- 3 files changed, 1501 insertions(+), 114 deletions(-) diff --git a/public/config.js b/public/config.js index 48f0ca1..9224385 100644 --- a/public/config.js +++ b/public/config.js @@ -4,5 +4,6 @@ window.RUNTIME_CONFIG = { VITE_CDN_DOMAIN: "s3-cdn-server-for-retrieving-files", VITE_SPACE_URL: "http://localhost:5403", VITE_SPACE_API_KEY: "", - VITE_ANALYTICS_SERVICE_URL: "http://localhost:3003" + VITE_ANALYTICS_SERVICE_URL: "http://localhost:3003", + VITE_SOCIAL_WS_URL: "http://localhost:3004" }; diff --git a/src/pages/app/messages/ConversationThreadPage.jsx b/src/pages/app/messages/ConversationThreadPage.jsx index 18072b9..36bdcd7 100644 --- a/src/pages/app/messages/ConversationThreadPage.jsx +++ b/src/pages/app/messages/ConversationThreadPage.jsx @@ -1,13 +1,101 @@ // src/pages/app/messages/ConversationThreadPage.jsx -import { useEffect, useRef, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; import { listMessages, sendMessage } from '@/services/messagingService'; -import { connectMessagingSocket, onMessageNew, offMessageNew, disconnectMessagingSocket } from '@/services/messagingSocket'; +import { onMessageNew, offMessageNew } from '@/services/messagingSocket'; +import { getProfileInfoByUserId } from '@/services/profileService'; function normalizeId(x) { return String(x || ''); } +function formatTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +function formatDayLabel(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const now = new Date(); + + const sameDay = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + + if (sameDay) return 'Hoy'; + + return d.toLocaleDateString(undefined, { day: '2-digit', month: 'short' }); +} + +function pickDisplayName(profile, fallbackId) { + const candidates = [ + profile?.displayName, + profile?.name, + profile?.fullName, + profile?.username, + profile?.userName, + ].filter(Boolean); + if (candidates.length > 0) return String(candidates[0]); + return fallbackId ? `Usuario ${fallbackId}` : 'Usuario'; +} + +function safeInitialFromName(nameOrId) { + const s = String(nameOrId || '').trim(); + if (!s) return 'U'; + const letter = s[0]?.toUpperCase() || 'U'; + return /[A-Z0-9]/i.test(letter) ? letter.toUpperCase() : 'U'; +} + +function hashToHue(input) { + const str = String(input || ''); + let h = 0; + for (let i = 0; i < str.length; i += 1) h = (h * 31 + str.charCodeAt(i)) >>> 0; + return h % 360; +} + +// Intenta sacar el userId del cliente (ajusta/añade keys si tu app usa otro nombre) +function getMyUserIdGuess() { + const keys = [ + 'userId', + 'uid', + 'currentUserId', + 'sb_userId', + 'socialbeats_userId', + 'auth_user_id', + ]; + + for (const k of keys) { + const v = typeof window !== 'undefined' ? window.localStorage?.getItem(k) : null; + if (v) return String(v); + } + + const jsonKeys = ['auth', 'user', 'currentUser', 'sb_auth', 'socialbeats_auth']; + for (const k of jsonKeys) { + try { + const raw = typeof window !== 'undefined' ? window.localStorage?.getItem(k) : null; + if (!raw) continue; + const obj = JSON.parse(raw); + const id = + obj?.userId || + obj?.id || + obj?.user?._id || + obj?.user?.id || + obj?.userId?._id || + obj?._id; + if (id) return String(id); + } catch { + // ignore + } + } + + return ''; +} + export default function ConversationThreadPage() { const { id: conversationId } = useParams(); @@ -21,21 +109,106 @@ export default function ConversationThreadPage() { const [text, setText] = useState(''); const [sending, setSending] = useState(false); - const knownMessageIdsRef = useRef(new Set()); // para evitar duplicados con message:new + POST response + const knownMessageIdsRef = useRef(new Set()); + const scrollerRef = useRef(null); + + // perfiles: userId -> { loading, data, error } + const [profiles, setProfiles] = useState({}); + const inFlightProfilesRef = useRef(new Set()); + + const myUserId = useMemo(() => getMyUserIdGuess(), []); + + const uniqueSenderIds = useMemo(() => { + const set = new Set(); + for (const m of items) { + if (m?.senderId) set.add(String(m.senderId)); + } + return Array.from(set); + }, [items]); + + const otherUserId = useMemo(() => { + if (myUserId) { + const other = uniqueSenderIds.find((id) => normalizeId(id) !== normalizeId(myUserId)); + return other || uniqueSenderIds[0] || ''; + } + const freq = new Map(); + for (const m of items) { + const sid = normalizeId(m?.senderId); + if (!sid) continue; + freq.set(sid, (freq.get(sid) || 0) + 1); + } + let best = ''; + let bestN = -1; + for (const [k, n] of freq.entries()) { + if (n > bestN) { + bestN = n; + best = k; + } + } + return best; + }, [items, myUserId, uniqueSenderIds]); + + const otherProfile = otherUserId ? profiles?.[otherUserId]?.data : null; + const otherName = pickDisplayName(otherProfile, otherUserId); + + async function ensureProfilesFor(userIds) { + const ids = Array.from(new Set((userIds || []).filter(Boolean).map((x) => String(x)))); + const toFetch = ids.filter((id) => !profiles[id] && !inFlightProfilesRef.current.has(id)); + if (toFetch.length === 0) return; + + toFetch.forEach((id) => inFlightProfilesRef.current.add(id)); + setProfiles((prev) => { + const next = { ...prev }; + toFetch.forEach((id) => { + next[id] = { loading: true, data: null, error: null }; + }); + return next; + }); + + await Promise.all( + toFetch.map(async (id) => { + try { + const data = await getProfileInfoByUserId(id); + setProfiles((prev) => ({ ...prev, [id]: { loading: false, data, error: null } })); + } catch (e) { + setProfiles((prev) => ({ ...prev, [id]: { loading: false, data: null, error: e } })); + } finally { + inFlightProfilesRef.current.delete(id); + } + }) + ); + } + + function scrollToBottom({ smooth = false } = {}) { + const el = scrollerRef.current; + if (!el) return; + const behavior = smooth ? 'smooth' : 'auto'; + el.scrollTo({ top: el.scrollHeight, behavior }); + } + + function isNearBottom() { + const el = scrollerRef.current; + if (!el) return true; + const threshold = 220; + const remaining = el.scrollHeight - (el.scrollTop + el.clientHeight); + return remaining < threshold; + } async function loadFirstPage() { setStatus('loading'); setError(null); try { - const data = await listMessages(conversationId, { limit: 30 }); + const data = await listMessages(conversationId, { limit: 40 }); const msgs = data.items || []; setItems(msgs); setHasMore(Boolean(data.hasMore)); setBefore(data.nextCursor || null); knownMessageIdsRef.current = new Set(msgs.map((m) => normalizeId(m._id))); + ensureProfilesFor(msgs.map((m) => m?.senderId)); setStatus('idle'); + requestAnimationFrame(() => scrollToBottom({ smooth: false })); } catch (e) { setStatus('error'); setError(e); @@ -44,17 +217,20 @@ export default function ConversationThreadPage() { async function loadOlder() { if (!hasMore || !before) return; + const el = scrollerRef.current; + + const prevScrollHeight = el?.scrollHeight || 0; + const prevScrollTop = el?.scrollTop || 0; + setStatus('loading'); setError(null); try { - const data = await listMessages(conversationId, { before, limit: 30 }); + const data = await listMessages(conversationId, { before, limit: 35 }); const older = data.items || []; setItems((prev) => { - // older viene en cronológico; lo insertamos al principio const merged = [...older, ...prev]; - // actualizar set de ids for (const m of older) knownMessageIdsRef.current.add(normalizeId(m._id)); return merged; }); @@ -62,7 +238,17 @@ export default function ConversationThreadPage() { setHasMore(Boolean(data.hasMore)); setBefore(data.nextCursor || null); + ensureProfilesFor(older.map((m) => m?.senderId)); + setStatus('idle'); + + requestAnimationFrame(() => { + const el2 = scrollerRef.current; + if (!el2) return; + const newScrollHeight = el2.scrollHeight; + const delta = newScrollHeight - prevScrollHeight; + el2.scrollTop = prevScrollTop + delta; + }); } catch (e) { setStatus('error'); setError(e); @@ -77,13 +263,16 @@ export default function ConversationThreadPage() { setSending(true); try { const res = await sendMessage(conversationId, { text: trimmed }); - // No optimista: añadimos si no llega por socket o para asegurar. const msg = res?.message; + if (msg && !knownMessageIdsRef.current.has(normalizeId(msg._id))) { knownMessageIdsRef.current.add(normalizeId(msg._id)); setItems((prev) => [...prev, msg]); + ensureProfilesFor([msg?.senderId]); } + setText(''); + requestAnimationFrame(() => scrollToBottom({ smooth: true })); } finally { setSending(false); } @@ -92,10 +281,10 @@ export default function ConversationThreadPage() { useEffect(() => { if (!conversationId) return; loadFirstPage(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [conversationId]); useEffect(() => { - const handler = ({ conversationId: incomingConvoId, message }) => { if (normalizeId(incomingConvoId) !== normalizeId(conversationId)) return; if (!message) return; @@ -103,63 +292,513 @@ export default function ConversationThreadPage() { const mid = normalizeId(message._id); if (knownMessageIdsRef.current.has(mid)) return; + const shouldStick = isNearBottom(); knownMessageIdsRef.current.add(mid); setItems((prev) => [...prev, message]); + ensureProfilesFor([message?.senderId]); - console.log("[WS] message:new received", incomingConvoId, message?._id); + requestAnimationFrame(() => { + if (shouldStick) scrollToBottom({ smooth: true }); + }); }; onMessageNew(handler); - return () => { - offMessageNew(handler); - }; + return () => offMessageNew(handler); }, [conversationId]); + // Separadores por día (WhatsApp) + const renderRows = useMemo(() => { + const norm = (s) => String(s || '').trim().toLowerCase(); + + const rows = []; + let lastDayLabel = ''; + + for (const m of items) { + const dayLabel = formatDayLabel(m?.createdAt); + if (dayLabel && dayLabel !== lastDayLabel) { + rows.push({ kind: 'day', key: `day-${dayLabel}-${m._id}`, dayLabel }); + lastDayLabel = dayLabel; + } + + const senderId = normalizeId(m?.senderId); + const prof = senderId ? profiles?.[senderId]?.data : null; + const senderName = pickDisplayName(prof, senderId); + + // Regla: coincide con el nombre de la conversación -> IZQ, si no -> DER + const alignRight = norm(senderName) !== norm(otherName); + + rows.push({ + kind: 'msg', + key: normalizeId(m?._id) || `${m?.createdAt || ''}-${senderId}-${m?.text || ''}`, + message: m, + alignRight, + }); + } + + return rows; + }, [items, profiles, otherName]); + + const CARD_MAX_HEIGHT = 'calc(100vh - 120px)'; + + const styles = { + page: { + width: '100%', + margin: 0, + padding: 0, + minHeight: '100vh', + position: 'relative', + overflow: 'visible', + boxSizing: 'border-box', + }, + + content: { position: 'relative', zIndex: 1 }, + + centered: { + maxWidth: 1200, + width: '100%', + margin: '0 auto', + padding: '18px 22px 28px', + boxSizing: 'border-box', + }, + + card: { + borderRadius: 22, + overflow: 'hidden', + border: '1px solid rgba(255,255,255,0.12)', + background: 'linear-gradient(180deg, rgba(255,255,255,0.12), rgba(255,255,255,0.06))', + boxShadow: '0 26px 85px rgba(0,0,0,0.28)', + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + maxHeight: CARD_MAX_HEIGHT, + height: CARD_MAX_HEIGHT, + display: 'flex', + flexDirection: 'column', + }, + + header: { + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '14px 16px', + background: 'linear-gradient(90deg, rgba(0,0,0,0.35), rgba(0,0,0,0.18))', + borderBottom: '1px solid rgba(255,255,255,0.10)', + flex: '0 0 auto', + }, + backBtn: { + width: 38, + height: 38, + borderRadius: 12, + display: 'grid', + placeItems: 'center', + border: '1px solid rgba(255,255,255,0.12)', + background: 'rgba(0,0,0,0.18)', + textDecoration: 'none', + color: 'rgba(255,255,255,0.92)', + boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06)', + transition: 'transform 140ms ease, background 140ms ease, border-color 140ms ease', + flex: '0 0 auto', + }, + backIcon: { width: 18, height: 18, opacity: 0.9 }, + + headerAvatar: { + width: 44, + height: 44, + borderRadius: 999, + display: 'grid', + placeItems: 'center', + color: 'rgba(255,255,255,0.95)', + fontWeight: 950, + border: '1px solid rgba(255,255,255,0.18)', + boxShadow: '0 14px 34px rgba(0,0,0,0.25)', + flex: '0 0 auto', + }, + headerTitleWrap: { minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }, + headerTitle: { + margin: 0, + fontSize: 19, + fontWeight: 950, + color: 'rgba(255,255,255,0.96)', + letterSpacing: -0.2, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + textAlign: 'left', + }, + headerSub: { + margin: 0, + fontSize: 13, + color: 'rgba(255,255,255,0.62)', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + + messagesWrap: { + flex: '1 1 auto', + minHeight: 0, + position: 'relative', + overflow: 'hidden', + background: 'rgb(232, 238, 251)', // mismo tono que chatBg para que “case” + }, + + // Fondo con figuritas más separadas e irregulares (sin animación) + // Fondo con doodles tipo WhatsApp, pero DIFUSO (en overlay) + chatBg: { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + + // Base NO blanca (se ve mucho más “premium”) + backgroundColor: 'rgba(30, 41, 59, 1)', + + // Mezcla más agradable con el fondo + mixBlendMode: 'multiply', + }, + + scroller: { + position: 'relative', + zIndex: 1, + height: '100%', + overflowY: 'auto', + padding: '18px 18px 20px', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + gap: 12, + }, + + loadOlderWrap: { display: 'flex', justifyContent: 'center', margin: '4px 0 8px' }, + loadOlderBtn: { + padding: '9px 13px', + borderRadius: 999, + border: '1px solid rgba(0,0,0,0.10)', + background: 'rgba(255,255,255,0.85)', + color: 'rgba(10,10,10,0.86)', + fontWeight: 900, + cursor: 'pointer', + boxShadow: '0 10px 24px rgba(0,0,0,0.10)', + }, + + daySeparatorWrap: { + display: 'flex', + justifyContent: 'center', + padding: '6px 0 2px', + }, + daySeparator: { + padding: '7px 12px', + borderRadius: 999, + border: '1px solid rgba(255,255,255,0.22)', + background: 'rgba(10,10,10,0.70)', + color: 'rgba(255,255,255,0.92)', + fontSize: 13, + fontWeight: 900, + letterSpacing: 0.2, + boxShadow: '0 10px 22px rgba(0,0,0,0.12)', + }, + + + row: { display: 'flex', width: '100%' }, + rowLeft: { justifyContent: 'flex-start' }, + rowRight: { justifyContent: 'flex-end' }, + + timeAbove: { + fontSize: 13.5, + fontWeight: 900, + opacity: 0.72, + margin: '0 8px 6px', + letterSpacing: 0.2, + userSelect: 'none', + }, + timeAboveLeft: { color: 'rgba(255,255,255,0.85)', textAlign: 'left' }, + timeAboveRight: { color: 'rgba(255,255,255,0.85)', textAlign: 'right' }, + + msgStack: { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + maxWidth: 'min(760px, 82%)', + }, + + bubbleBase: { + borderRadius: 22, + padding: '14px 26px', + minHeight: 54, + border: '1px solid rgba(0,0,0,0.08)', + boxShadow: '0 14px 26px rgba(0,0,0,0.10)', + position: 'relative', + }, + bubbleLeft: { + background: 'rgba(255,255,255,0.88)', + color: 'rgba(10,10,10,0.88)', + }, + bubbleRight: { + background: 'linear-gradient(180deg, rgba(253,230,138,0.92), rgba(252,211,77,0.88))', + color: 'rgba(10,10,10,0.92)', + border: '1px solid rgba(0,0,0,0.10)', + }, + + text: { + fontSize: 16.5, + lineHeight: 1.38, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + + composer: { + flex: '0 0 auto', + padding: '12px 12px', + borderTop: '1px solid rgba(255,255,255,0.10)', + background: 'linear-gradient(180deg, rgba(0,0,0,0.18), rgba(0,0,0,0.10))', + display: 'flex', + gap: 10, + alignItems: 'center', + }, + input: { + flex: 1, + padding: '12px 14px', + borderRadius: 16, + outline: 'none', + border: '1px solid rgba(255,255,255,0.16)', + background: 'rgba(0,0,0,0.20)', + color: 'rgba(255,255,255,0.92)', + fontSize: 16, + boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06)', + }, + sendBtn: { + height: 46, + padding: '0 14px', + borderRadius: 14, + border: '1px solid rgba(0,0,0,0.16)', + background: 'linear-gradient(180deg, rgba(253,230,138,0.98), rgba(252,211,77,0.96))', + color: 'rgba(10,10,10,0.92)', + fontWeight: 950, + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + gap: 10, + boxShadow: '0 14px 28px rgba(0,0,0,0.24)', + userSelect: 'none', + whiteSpace: 'nowrap', + transition: 'transform 140ms ease, filter 140ms ease', + fontSize: 15.5, + }, + sendCircle: { + width: 30, + height: 30, + borderRadius: 999, + background: 'rgba(0,0,0,0.14)', + border: '1px solid rgba(0,0,0,0.18)', + display: 'grid', + placeItems: 'center', + boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.22)', + }, + sendIcon: { width: 16, height: 16, color: 'rgba(10,10,10,0.92)' }, + + errorBox: { + marginTop: 10, + padding: 12, + borderRadius: 14, + border: '1px solid rgba(255,255,255,0.12)', + background: 'rgba(0,0,0,0.22)', + color: 'rgba(255,255,255,0.90)', + }, + errorBtn: { + marginTop: 10, + padding: '10px 12px', + borderRadius: 12, + border: '1px solid rgba(255,255,255,0.14)', + background: 'rgba(0,0,0,0.22)', + color: 'rgba(255,255,255,0.90)', + fontWeight: 900, + cursor: 'pointer', + }, + }; + + const otherHue = hashToHue(otherUserId || conversationId); + const otherAvatarBg = `linear-gradient(135deg, hsla(${otherHue}, 85%, 58%, 0.95), hsla(${ + (otherHue + 28) % 360 + }, 85%, 48%, 0.95))`; + return ( -
-

Conversación

-
{conversationId}
- - {status === 'error' && ( -
-

No se pudieron cargar los mensajes.

- -
- )} - -
- {hasMore && ( - - )} -
+
+ + +
+
+
+
+ {/* Header */} +
+ { + e.currentTarget.style.transform = 'translateY(-1px)'; + e.currentTarget.style.background = 'rgba(0,0,0,0.26)'; + e.currentTarget.style.borderColor = 'rgba(255,255,255,0.18)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0px)'; + e.currentTarget.style.background = 'rgba(0,0,0,0.18)'; + e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)'; + }} + aria-label="Volver a chats" + title="Volver" + > + + + +
+ {safeInitialFromName(otherName)} +
+ +
+

+ {otherName} +

+

+ {status === 'loading' ? 'Cargando mensajes…' : 'Chat activo'} +

+
+
-
- {items.map((m) => ( -
-
{String(m.senderId)}
-
{m.text}
-
- {m.createdAt ? new Date(m.createdAt).toLocaleString() : ''} + {status === 'error' && ( +
+
No se pudieron cargar los mensajes.
+ + {error ? ( +
+ {String(error?.message || error)} +
+ ) : null} +
+ )} + + {/* Scroll area */} +
+ + + {/* Composer (siempre visible) */} +
+ setText(e.target.value)} + placeholder="Escribe un mensaje…" + style={styles.input} + maxLength={1000} + disabled={sending} + /> + +
- ))} +
- -
- setText(e.target.value)} - placeholder="Escribe un mensaje…" - style={{ flex: 1, padding: 8 }} - maxLength={1000} - disabled={sending} - /> - -
); } diff --git a/src/pages/app/messages/ConversationsPage.jsx b/src/pages/app/messages/ConversationsPage.jsx index 8124c24..84b2316 100644 --- a/src/pages/app/messages/ConversationsPage.jsx +++ b/src/pages/app/messages/ConversationsPage.jsx @@ -1,14 +1,53 @@ // src/pages/app/messages/ConversationsPage.jsx -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { listConversations } from '@/services/messagingService'; -import { connectMessagingSocket, onMessageNew, offMessageNew, disconnectMessagingSocket } from '@/services/messagingSocket'; +import { onMessageNew, offMessageNew } from '@/services/messagingSocket'; +import { getProfileInfoByUserId } from '@/services/profileService'; -function formatDate(isoOrDate) { - if (!isoOrDate) return ''; +function formatDateAndTime(isoOrDate) { + if (!isoOrDate) return { date: '', time: '' }; const d = new Date(isoOrDate); - if (Number.isNaN(d.getTime())) return ''; - return d.toLocaleString(); + if (Number.isNaN(d.getTime())) return { date: '', time: '' }; + + const now = new Date(); + const isSameDay = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + + const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const date = isSameDay + ? 'Hoy' + : d.toLocaleDateString(undefined, { day: '2-digit', month: 'short' }); + + return { date, time }; +} + +function safeInitialFromName(nameOrId) { + const s = String(nameOrId || '').trim(); + if (!s) return 'C'; + const letter = s[0]?.toUpperCase() || 'C'; + return /[A-Z0-9]/i.test(letter) ? letter.toUpperCase() : 'C'; +} + +function hashToHue(input) { + const str = String(input || ''); + let h = 0; + for (let i = 0; i < str.length; i += 1) h = (h * 31 + str.charCodeAt(i)) >>> 0; + return h % 360; +} + +function pickDisplayName(profile, fallbackId) { + const candidates = [ + profile?.displayName, + profile?.name, + profile?.fullName, + profile?.username, + profile?.userName, + ].filter(Boolean); + if (candidates.length > 0) return String(candidates[0]); + return fallbackId ? `Usuario ${fallbackId}` : 'Conversación'; } export default function ConversationsPage() { @@ -21,16 +60,25 @@ export default function ConversationsPage() { const [query, setQuery] = useState(''); + // Cache de perfiles (userId -> { loading, data, error }) + const [profiles, setProfiles] = useState({}); + const inFlightRef = useRef(new Set()); + const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return items; - // V1: solo podemos filtrar por otherUserId/lastMessageText, porque no tenemos perfiles todavía. + return items.filter((c) => { - const other = String(c.otherUserId || '').toLowerCase(); + const otherId = String(c.otherUserId || '').toLowerCase(); const last = String(c.lastMessageText || '').toLowerCase(); - return other.includes(q) || last.includes(q); + const prof = profiles?.[c.otherUserId]?.data; + const name = String( + prof?.displayName || prof?.name || prof?.fullName || prof?.username || '' + ).toLowerCase(); + + return otherId.includes(q) || last.includes(q) || name.includes(q); }); - }, [items, query]); + }, [items, query, profiles]); async function loadFirstPage() { setStatus('loading'); @@ -63,20 +111,64 @@ export default function ConversationsPage() { } } + async function ensureProfilesFor(conversations) { + const userIds = Array.from( + new Set( + (conversations || []) + .map((c) => c?.otherUserId) + .filter(Boolean) + .map((id) => String(id)) + ) + ); + + const toFetch = userIds.filter((id) => !profiles[id] && !inFlightRef.current.has(id)); + if (toFetch.length === 0) return; + + toFetch.forEach((id) => inFlightRef.current.add(id)); + setProfiles((prev) => { + const next = { ...prev }; + toFetch.forEach((id) => { + next[id] = { loading: true, data: null, error: null }; + }); + return next; + }); + + await Promise.all( + toFetch.map(async (id) => { + try { + const data = await getProfileInfoByUserId(id); + setProfiles((prev) => ({ + ...prev, + [id]: { loading: false, data, error: null }, + })); + } catch (e) { + setProfiles((prev) => ({ + ...prev, + [id]: { loading: false, data: null, error: e }, + })); + } finally { + inFlightRef.current.delete(id); + } + }) + ); + } + useEffect(() => { loadFirstPage(); }, []); + useEffect(() => { + if (items?.length) ensureProfilesFor(items); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]); + useEffect(() => { const handler = ({ conversationId, message }) => { if (!conversationId || !message) return; setItems((prev) => { - // Actualiza lastMessage* y mueve arriba si ya existe en inbox. const idx = prev.findIndex((c) => String(c._id) === String(conversationId)); if (idx === -1) { - // V1: si no está, no inventamos un objeto; pedimos refetch. - // Para no spamear, podrías debouncear; en V1 hacemos simple: loadFirstPage(); return prev; } @@ -86,64 +178,719 @@ export default function ConversationsPage() { updated.lastMessageText = String(message.text || '').slice(0, 200); const without = prev.filter((_, i) => i !== idx); - return [updated, ...without]; + const next = [updated, ...without]; + + ensureProfilesFor(next); + return next; }); - console.log("[WS] inbox event", conversationId, message?._id); + + // eslint-disable-next-line no-console + console.log('[WS] inbox event', conversationId, message?._id); }; onMessageNew(handler); - return () => { - offMessageNew(handler); - }; + return () => offMessageNew(handler); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const MIN_ROWS_VISUAL = 6; + const rowHeightApprox = 88; + const fillerCount = Math.max(0, MIN_ROWS_VISUAL - filtered.length); + + // Ajuste para que el botón tenga EXACTAMENTE la misma altura que (fecha+hora+gap) + const DATE_TIME_GAP = 6; + const DATE_TIME_PAD_Y = 6; + const DATE_TIME_LINE_H = 14; + const DATE_TIME_HEIGHT = 2 * (DATE_TIME_LINE_H + DATE_TIME_PAD_Y * 2) + DATE_TIME_GAP; // ~62px + + const styles = { + page: { + width: '100%', + margin: 0, + padding: 0, + position: 'relative', + display: 'block', + minHeight: '100vh', + overflow: 'visible', + boxSizing: 'border-box', + }, + + centered: { + maxWidth: 1200, + width: '100%', + margin: '0 auto', + padding: '22px 22px 30px', + boxSizing: 'border-box', + position: 'relative', + }, + + reset: { boxSizing: 'border-box' }, + + decorWrap: { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + overflow: 'visible', + zIndex: 0, + }, + decorCanvas: { + position: 'absolute', + inset: -260, + pointerEvents: 'none', + overflow: 'visible', + }, + + // ====== BLOBS PRINCIPALES ====== + blobA: { + position: 'absolute', + width: 620, + height: 620, + left: -180, + top: -180, + borderRadius: 999, + background: + 'radial-gradient(circle at 30% 30%, rgba(168,85,247,0.22), transparent 62%)', + opacity: 0.95, + animation: 'sbFloatA 12s ease-in-out infinite', + filter: 'blur(0.2px)', + }, + blobB: { + position: 'absolute', + width: 760, + height: 760, + right: -260, + top: -40, + borderRadius: 999, + background: + 'radial-gradient(circle at 70% 30%, rgba(59,130,246,0.18), transparent 60%)', + opacity: 0.9, + animation: 'sbFloatB 14s ease-in-out infinite', + }, + blobC: { + position: 'absolute', + width: 920, + height: 920, + left: '20%', + bottom: -620, + borderRadius: 999, + background: + 'radial-gradient(circle at 50% 50%, rgba(236,72,153,0.10), transparent 62%)', + opacity: 0.85, + animation: 'sbFloatC 16s ease-in-out infinite', + }, + + // ====== BURBUJAS EXTRA (NUEVAS) ====== + bubble1: { + position: 'absolute', + width: 260, + height: 260, + left: '8%', + top: '18%', + borderRadius: 999, + background: + 'radial-gradient(circle at 30% 30%, rgba(56,189,248,0.18), transparent 62%)', + opacity: 0.75, + animation: 'sbBubble1 18s ease-in-out infinite', + filter: 'blur(0.6px)', + }, + bubble2: { + position: 'absolute', + width: 320, + height: 320, + right: '10%', + top: '28%', + borderRadius: 999, + background: + 'radial-gradient(circle at 70% 35%, rgba(167,139,250,0.18), transparent 62%)', + opacity: 0.7, + animation: 'sbBubble2 22s ease-in-out infinite', + filter: 'blur(0.8px)', + }, + bubble3: { + position: 'absolute', + width: 220, + height: 220, + left: '36%', + top: '6%', + borderRadius: 999, + background: + 'radial-gradient(circle at 55% 35%, rgba(244,114,182,0.12), transparent 64%)', + opacity: 0.7, + animation: 'sbBubble3 20s ease-in-out infinite', + }, + bubble4: { + position: 'absolute', + width: 360, + height: 360, + right: '-6%', + bottom: '14%', + borderRadius: 999, + background: + 'radial-gradient(circle at 40% 45%, rgba(34,211,238,0.14), transparent 64%)', + opacity: 0.65, + animation: 'sbBubble4 24s ease-in-out infinite', + filter: 'blur(0.8px)', + }, + bubble5: { + position: 'absolute', + width: 240, + height: 240, + left: '-4%', + bottom: '18%', + borderRadius: 999, + background: + 'radial-gradient(circle at 55% 55%, rgba(59,130,246,0.12), transparent 66%)', + opacity: 0.6, + animation: 'sbBubble5 26s ease-in-out infinite', + filter: 'blur(0.7px)', + }, + + content: { position: 'relative', zIndex: 1 }, + + headerRow: { + display: 'flex', + alignItems: 'flex-end', + justifyContent: 'space-between', + gap: 12, + margin: '0 0 18px 0', + padding: 0, + }, + titleWrap: { display: 'flex', flexDirection: 'column', gap: 8 }, + title: { + margin: 0, + fontSize: 44, + letterSpacing: -0.9, + fontWeight: 950, + color: 'rgba(255,255,255,0.97)', + textShadow: '0 12px 30px rgba(0,0,0,0.35)', + lineHeight: 1.05, + }, + + shell: { + width: '100%', + borderRadius: 22, + padding: 18, + background: + 'linear-gradient(180deg, rgba(255,255,255,0.12), rgba(255,255,255,0.06))', + border: '1px solid rgba(255,255,255,0.12)', + boxShadow: '0 26px 85px rgba(0,0,0,0.28)', + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + position: 'relative', + overflow: 'hidden', + boxSizing: 'border-box', + minHeight: 560, + }, + + shellPattern: { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + opacity: 0.22, + backgroundImage: 'radial-gradient(rgba(255,255,255,0.12) 1px, transparent 1px)', + backgroundSize: '18px 18px', + maskImage: + 'linear-gradient(to bottom, rgba(0,0,0,1), rgba(0,0,0,0.55), rgba(0,0,0,0))', + WebkitMaskImage: + 'linear-gradient(to bottom, rgba(0,0,0,1), rgba(0,0,0,0.55), rgba(0,0,0,0))', + }, + + searchWrap: { position: 'relative', margin: '6px 0 18px' }, + searchIcon: { + position: 'absolute', + left: 14, + top: '50%', + transform: 'translateY(-50%)', + width: 18, + height: 18, + opacity: 0.78, + pointerEvents: 'none', + color: 'rgba(255,255,255,0.88)', + }, + input: { + width: '100%', + padding: '14px 14px 14px 42px', + borderRadius: 16, + outline: 'none', + border: '1px solid rgba(255,255,255,0.16)', + background: 'linear-gradient(180deg, rgba(0,0,0,0.32), rgba(0,0,0,0.22))', + color: 'rgba(255,255,255,0.92)', + fontSize: 16, + boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.08), 0 10px 28px rgba(0,0,0,0.14)', + boxSizing: 'border-box', + }, + + list: { + listStyle: 'none', + padding: 0, + margin: 0, + display: 'flex', + flexDirection: 'column', + gap: 12, + }, + + item: { + borderRadius: 20, + border: '1px solid rgba(255,255,255,0.12)', + background: 'linear-gradient(180deg, rgba(255,255,255,0.095), rgba(255,255,255,0.05))', + boxShadow: '0 14px 36px rgba(0,0,0,0.22)', + overflow: 'hidden', + transition: + 'transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease', + }, + + itemInner: { + display: 'flex', + alignItems: 'center', + gap: 14, + padding: '16px 16px', + minHeight: 88, + }, + + avatar: { + width: 58, + height: 58, + borderRadius: 999, + display: 'grid', + placeItems: 'center', + flex: '0 0 auto', + color: 'rgba(255,255,255,0.95)', + fontWeight: 950, + fontSize: 18, + letterSpacing: 0.3, + border: '1px solid rgba(255,255,255,0.22)', + boxShadow: '0 16px 42px rgba(0,0,0,0.26)', + }, + + main: { + flex: 1, + minWidth: 0, + textAlign: 'left', + display: 'grid', + gridTemplateColumns: '1fr auto', + gridTemplateRows: 'auto auto', + columnGap: 14, + rowGap: 6, + alignItems: 'center', + }, + + name: { + gridColumn: '1 / 2', + gridRow: '1 / 2', + justifySelf: 'start', + fontWeight: 950, + fontSize: 18, + color: 'rgba(255,255,255,0.96)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + letterSpacing: -0.2, + margin: 0, + lineHeight: 1.1, + }, + + preview: { + gridColumn: '1 / 2', + gridRow: '2 / 3', + justifySelf: 'start', + fontSize: 15.5, + color: 'rgba(255,255,255,0.76)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + lineHeight: 1.25, + textAlign: 'left', + margin: 0, + }, + + dateTimeWrap: { + gridColumn: '2 / 3', + gridRow: '1 / 3', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'stretch', + gap: DATE_TIME_GAP, + minWidth: 84, + }, + datePill: { + padding: `${DATE_TIME_PAD_Y}px 12px`, + borderRadius: 12, + background: 'rgba(0,0,0,0.18)', + border: '1px solid rgba(255,255,255,0.10)', + textAlign: 'center', + lineHeight: `${DATE_TIME_LINE_H}px`, + }, + timePill: { + padding: `${DATE_TIME_PAD_Y}px 12px`, + borderRadius: 12, + background: 'rgba(0,0,0,0.18)', + border: '1px solid rgba(255,255,255,0.10)', + textAlign: 'center', + lineHeight: `${DATE_TIME_LINE_H}px`, + }, + dateText: { + fontSize: 13, + color: 'rgba(255,255,255,0.72)', + fontWeight: 850, + letterSpacing: 0.2, + whiteSpace: 'nowrap', + }, + timeText: { + fontSize: 13, + color: 'rgba(255,255,255,0.56)', + fontWeight: 800, + whiteSpace: 'nowrap', + }, + + rightCol: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + flex: '0 0 auto', + marginLeft: 10, + }, + + openButton: { + height: DATE_TIME_HEIGHT, + minWidth: 112, + padding: '0 14px', + borderRadius: 14, + border: '1px solid rgba(0,0,0,0.18)', + background: 'linear-gradient(180deg, rgba(253,230,138,0.98), rgba(252,211,77,0.98))', + color: 'rgba(10,10,10,0.92)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + fontSize: 15, + fontWeight: 950, + textDecoration: 'none', + boxShadow: '0 16px 36px rgba(0,0,0,0.22)', + cursor: 'pointer', + userSelect: 'none', + whiteSpace: 'nowrap', + transition: 'transform 150ms ease, filter 150ms ease, box-shadow 150ms ease', + }, + openLabel: { lineHeight: 1, paddingTop: 1 }, + playCircle: { + width: 34, + height: 34, + borderRadius: 999, + background: 'rgba(0,0,0,0.14)', + border: '1px solid rgba(0,0,0,0.18)', + display: 'grid', + placeItems: 'center', + flex: '0 0 auto', + boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.22)', + }, + playIcon: { width: 16, height: 16, color: 'rgba(10,10,10,0.92)' }, + + fillerArea: { + marginTop: 12, + borderRadius: 18, + border: '1px dashed rgba(255,255,255,0.10)', + background: 'linear-gradient(180deg, rgba(0,0,0,0.18), rgba(0,0,0,0.10))', + minHeight: fillerCount > 0 ? fillerCount * rowHeightApprox : 0, + opacity: 0.9, + position: 'relative', + overflow: 'hidden', + }, + + stateBox: { + padding: 14, + borderRadius: 16, + border: '1px solid rgba(255,255,255,0.12)', + background: 'rgba(0,0,0,0.22)', + color: 'rgba(255,255,255,0.85)', + marginTop: 10, + }, + + button: { + padding: '10px 12px', + borderRadius: 12, + border: '1px solid rgba(255,255,255,0.14)', + background: 'rgba(0,0,0,0.22)', + color: 'rgba(255,255,255,0.90)', + fontWeight: 900, + cursor: 'pointer', + }, + loadMoreWrap: { marginTop: 16, display: 'flex', justifyContent: 'center' }, + helperRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + marginTop: 12, + }, + infoText: { + margin: 0, + fontSize: 13, + color: 'rgba(255,255,255,0.68)', + }, + + rowDivider: { + height: 1, + background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.10), transparent)', + }, + }; + return ( -
-

Mensajes

- -
- setQuery(e.target.value)} - placeholder="Buscar por usuario (ID) o texto…" - style={{ width: '100%', padding: 8 }} - /> -
+
+ + +
+