From f9e1505147e31eac2b73817c691034cb82048208 Mon Sep 17 00:00:00 2001 From: SocioWire Date: Fri, 9 Jan 2026 20:40:28 +0000 Subject: [PATCH] feat: add real-time chat system with i18n support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WebSocket-based chat with presence indicators - Add message delivery status (sending/sent/read) - Add notification badges for unread messages - Add body scroll lock on mobile to prevent keyboard issues - Add visibility change handling to refresh data on browser resume - Add internationalization (i18n) with English/French support - Add timezone-aware message timestamps 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 154 ++++++ package.json | 6 +- src/App.jsx | 88 +-- src/api/client.js | 53 ++ src/auth/AuthContext.jsx | 30 +- src/components/Chat/ChatContainer.css | 458 ++++++++++++++++ src/components/Chat/ChatContainer.jsx | 532 +++++++++++++++++++ src/components/Chat/ChatWindow.css | 324 +++++++++++ src/components/Chat/ChatWindow.jsx | 218 ++++++++ src/components/Chat/cryptoUtils.js | 192 +++++++ src/components/Chat/useChat.js | 382 +++++++++++++ src/components/Filters/FilterButtons.jsx | 15 +- src/components/Filters/TimeFilterButtons.jsx | 13 +- src/components/Friends/Friends.css | 45 ++ src/components/Friends/FriendsList.jsx | 135 +++-- src/components/Island/IslandUniverse.jsx | 12 +- src/components/Island/IslandViewer.jsx | 61 ++- src/components/Layout/TopBar.jsx | 230 ++++++-- src/components/Map/MapControls.jsx | 6 +- src/components/Map/markerManager.js | 149 +++++- src/components/Posts/PostCard.jsx | 56 +- src/components/Posts/PostList.jsx | 6 +- src/components/Search/SmartSearchBar.jsx | 19 +- src/i18n/index.js | 30 ++ src/locales/en/translation.json | 276 ++++++++++ src/locales/es/translation.json | 276 ++++++++++ src/locales/fr/translation.json | 279 ++++++++++ src/main.jsx | 1 + src/styles/topbar.css | 310 ++++++++++- src/utils/timezone.js | 149 ++++++ 30 files changed, 4251 insertions(+), 254 deletions(-) create mode 100644 src/components/Chat/ChatContainer.css create mode 100644 src/components/Chat/ChatContainer.jsx create mode 100644 src/components/Chat/ChatWindow.css create mode 100644 src/components/Chat/ChatWindow.jsx create mode 100644 src/components/Chat/cryptoUtils.js create mode 100644 src/components/Chat/useChat.js create mode 100644 src/i18n/index.js create mode 100644 src/locales/en/translation.json create mode 100644 src/locales/es/translation.json create mode 100644 src/locales/fr/translation.json create mode 100644 src/utils/timezone.js diff --git a/package-lock.json b/package-lock.json index f4f01d5..65beccb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,15 @@ "dependencies": { "@fortawesome/fontawesome-free": "^7.1.0", "axios": "^1.13.2", + "i18next": "^25.7.4", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "keycloak-js": "^26.2.1", "livekit-client": "^2.0.0", "maplibre-gl": "^5.14.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^16.5.1", "react-router-dom": "^7.10.1" }, "devDependencies": { @@ -247,6 +251,14 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -1709,6 +1721,14 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2347,6 +2367,60 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.7.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz", + "integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2694,6 +2768,25 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -2879,6 +2972,32 @@ "react": "^19.2.3" } }, + "node_modules/react-i18next": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz", + "integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3098,6 +3217,11 @@ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/ts-debounce": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz", @@ -3167,6 +3291,14 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -3226,6 +3358,19 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, "node_modules/webrtc-adapter": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz", @@ -3238,6 +3383,15 @@ "npm": ">=3.10.0" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 720aa65..76f3093 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,16 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^7.1.0", - "livekit-client": "^2.0.0", "axios": "^1.13.2", + "i18next": "^25.7.4", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "keycloak-js": "^26.2.1", + "livekit-client": "^2.0.0", "maplibre-gl": "^5.14.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^16.5.1", "react-router-dom": "^7.10.1" }, "devDependencies": { diff --git a/src/App.jsx b/src/App.jsx index 90a52aa..8d512f3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,16 +1,18 @@ import React, { Suspense, useEffect, useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import TopBar from "./components/Layout/TopBar"; import PostList from "./components/Posts/PostList"; import IslandUniverse from "./components/Island/IslandUniverse"; import IslandViewer from "./components/Island/IslandViewer"; +import ChatContainer from "./components/Chat/ChatContainer"; import { fetchIslandByUsername } from "./api/client"; const MapView = React.lazy(() => import("./components/Map/MapView")); const THEME_KEY = "sociowire:theme"; -const ACTIVE_CHATS_KEY = "sociowire:activeChats"; export default function App() { + const { t } = useTranslation(); const [theme, setTheme] = useState("blue"); // Wall state (lifted from MapView) @@ -77,10 +79,6 @@ export default function App() { } }, [theme]); - // Chats actifs (s'ajoutent quand on clique sur un contact) - const [activeChats, setActiveChats] = useState([]); - const [showContactsList, setShowContactsList] = useState(false); - useEffect(() => { const onScroll = () => { const threshold = window.innerHeight * 0.55; @@ -92,29 +90,6 @@ export default function App() { return () => window.removeEventListener("scroll", onScroll); }, []); - useEffect(() => { - try { - const saved = localStorage.getItem(ACTIVE_CHATS_KEY); - if (saved) setActiveChats(JSON.parse(saved)); - } catch {} - }, []); - - useEffect(() => { - try { - localStorage.setItem(ACTIVE_CHATS_KEY, JSON.stringify(activeChats)); - } catch {} - }, [activeChats]); - - // Ouvrir un chat - const openChat = (name) => { - if (!activeChats.includes(name)) { - setActiveChats(prev => [...prev, name]); - } - setShowContactsList(false); - }; - - const contactsList = ["Amely", "Nancy", "Stéphanie"]; - // Island Universe handlers const handleOpenUniverse = () => { setShowUniverse(true); @@ -157,8 +132,8 @@ export default function App() {
-
Loading map
-
Warming up tiles and live data.
+
{t('map.loadingMap')}
+
{t('map.warmingUp')}
@@ -216,46 +191,19 @@ export default function App() {
-
Communities
-

Explore Islands

+
{t('islands.communities')}
+

{t('islands.exploreIslands')}

- Islands are local hubs where people share updates, deals, and stories in one place. - Browse nearby islands or jump into a new community to see what is trending. + {t('islands.islandsDescription')}

- {/* Bouton Contacts flottant en bas à droite */} - - - {/* Liste des contacts (dropdown) */} - {showContactsList && ( -
- {contactsList.map((name, idx) => ( -
openChat(name)}> -
👤
- {name} -
- ))} -
- )} - - {/* Bulles de chats actifs flottantes à côté du bouton */} -
- {activeChats.map((name, idx) => ( -
-
👤
- {name} - -
- ))} -
+ {/* Chat System */} +
diff --git a/src/api/client.js b/src/api/client.js index 1f5f73d..58ea727 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -931,6 +931,21 @@ export async function rejectFriendRequest(username) { } } +export async function cancelFriendRequest(username) { + try { + const res = await fetch(`${apiBase()}/friends/cancel`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ username }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + export async function removeFriend(username) { try { const res = await fetch(`${apiBase()}/friends/remove`, { @@ -961,3 +976,41 @@ export async function fetchFriendStatus(username) { return { ok: false, status: "none" }; } } + +export async function searchUsers(query) { + if (!query || query.length < 2) return { ok: true, users: [] }; + const qs = new URLSearchParams(); + qs.set("q", query); + try { + const res = await fetch(`${apiBase()}/users/search?${qs.toString()}`, { + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false, users: [] }; + } +} + +export async function updateTimezone(timezone) { + try { + const res = await fetch(`${apiBase()}/profile/timezone`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ timezone }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +export function getBrowserTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; + } catch { + return ""; + } +} diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index 5f87692..e85a1c9 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -458,18 +458,38 @@ export function AuthProvider({ children }) { async function logout() { if (refreshTimer.current) clearInterval(refreshTimer.current); refreshTimer.current = null; - const redirectUri = window.location.origin + window.location.pathname; + const redirectUri = window.location.origin; // Don't do SSO logout for apps subdomain - keep it isolated const doSsoLogout = keycloakEnabled() && !isAppsSubdomain(); + + // Get id_token before clearing auth (needed for proper Keycloak logout) + const idToken = tokens?.id_token || ""; + + console.log("[Auth] Logout initiated, doSsoLogout:", doSsoLogout, "hasIdToken:", !!idToken); + try { sessionStorage.setItem("sociowire:logout", String(Date.now())); } catch {} + + // Clear local auth state setAnon(""); + if (!doSsoLogout) return; - try { - const kc = getKeycloak(); - await kc.logout({ redirectUri }); - } catch {} + + // Build Keycloak logout URL + const logoutUrl = `${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/logout`; + const params = new URLSearchParams(); + params.set("client_id", KC_CLIENT_ID); + params.set("post_logout_redirect_uri", redirectUri); + if (idToken) { + params.set("id_token_hint", idToken); + } + + const fullLogoutUrl = `${logoutUrl}?${params.toString()}`; + console.log("[Auth] Redirecting to logout URL:", fullLogoutUrl); + + // Redirect to Keycloak logout - this will clear the SSO session + window.location.href = fullLogoutUrl; } async function resendVerificationEmail() { diff --git a/src/components/Chat/ChatContainer.css b/src/components/Chat/ChatContainer.css new file mode 100644 index 0000000..b232873 --- /dev/null +++ b/src/components/Chat/ChatContainer.css @@ -0,0 +1,458 @@ +/* Chat Bottom Bar */ +.chat-bottom-bar { + position: fixed; + bottom: 16px; + right: 16px; + display: flex; + align-items: center; + gap: 10px; + z-index: 9980; +} + +/* Chat Tab (minimized chat) */ +.chat-tab { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px 6px 6px; + background: linear-gradient(135deg, rgba(30, 58, 138, 0.95), rgba(59, 130, 246, 0.9)); + border-radius: 999px; + color: #f1f5f9; + font-size: 13px; + font-weight: 600; + border: 1px solid rgba(148, 163, 184, 0.3); + cursor: pointer; + backdrop-filter: blur(10px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + transition: transform 0.2s, box-shadow 0.2s, background 0.2s; +} + +.chat-tab:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4); +} + +.chat-tab.online { + border-color: rgba(34, 197, 94, 0.5); + box-shadow: 0 4px 16px rgba(34, 197, 94, 0.3); +} + +.chat-tab-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: linear-gradient(135deg, #3b82f6, #8b5cf6); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 12px; + overflow: hidden; + flex-shrink: 0; +} + +.chat-tab-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.chat-tab-name { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-tab-close { + width: 20px; + height: 20px; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + margin-left: 4px; + transition: background 0.2s, color 0.2s; +} + +.chat-tab-close:hover { + background: rgba(239, 68, 68, 0.8); + color: white; +} + +/* Unread badge on chat tab */ +.chat-tab-unread { + min-width: 18px; + height: 18px; + padding: 0 5px; + background: #ef4444; + color: white; + font-size: 10px; + font-weight: 700; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + animation: pulse 2s infinite; +} + +.chat-tab.has-unread { + animation: shake 0.5s ease-in-out; +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +@keyframes shake { + 0%, 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-3px); + } + 75% { + transform: translateX(3px); + } +} + +/* Contacts Float Button */ +.contacts-float-btn { + padding: 10px 16px; + background: linear-gradient(135deg, rgba(30, 58, 138, 0.9), rgba(59, 130, 246, 0.85)); + border-radius: 999px; + color: #f1f5f9; + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(148, 163, 184, 0.3); + cursor: pointer; + backdrop-filter: blur(10px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + transition: transform 0.2s, box-shadow 0.2s; + position: relative; +} + +.contacts-float-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4); +} + +.contacts-float-btn .contact-icon { + font-size: 18px; +} + +.contacts-float-btn .contact-label { + display: inline; +} + +.contacts-badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 20px; + height: 20px; + padding: 0 6px; + background: #ef4444; + color: white; + font-size: 11px; + font-weight: 700; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; +} + +.connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(148, 163, 184, 0.5); + margin-left: 4px; +} + +.connection-dot.online { + background: #22c55e; + box-shadow: 0 0 8px rgba(34, 197, 94, 0.6); +} + +/* Contacts Dropdown */ +.contacts-dropdown { + position: fixed; + bottom: 70px; + right: 16px; + width: 280px; + max-height: 400px; + background: rgba(15, 23, 42, 0.98); + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 16px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + z-index: 10000; + overflow: hidden; + backdrop-filter: blur(12px); + animation: slideUp 0.2s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.contacts-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); + font-weight: 600; + font-size: 15px; + color: #f1f5f9; +} + +.contacts-close { + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: #94a3b8; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: background 0.2s; +} + +.contacts-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +.contacts-list { + max-height: 340px; + overflow-y: auto; + padding: 8px; +} + +.contacts-empty { + padding: 30px 16px; + text-align: center; + color: rgba(148, 163, 184, 0.7); + font-size: 13px; +} + +.contact-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 10px; + cursor: pointer; + transition: background 0.2s; +} + +.contact-item:hover { + background: rgba(59, 130, 246, 0.15); +} + +.contact-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #3b82f6, #8b5cf6); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 16px; + position: relative; + overflow: hidden; + flex-shrink: 0; +} + +.contact-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.contact-avatar.online::after { + content: ""; + position: absolute; + bottom: 1px; + right: 1px; + width: 12px; + height: 12px; + background: #22c55e; + border: 2px solid rgba(15, 23, 42, 0.98); + border-radius: 50%; +} + +.contact-name { + flex: 1; + font-weight: 500; + font-size: 14px; + color: #e2e8f0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.online-badge { + width: 8px; + height: 8px; + border-radius: 50%; + background: #22c55e; +} + +/* Unread badge in contacts list */ +.contact-unread-badge { + min-width: 18px; + height: 18px; + padding: 0 5px; + background: #ef4444; + color: white; + font-size: 11px; + font-weight: 700; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; +} + +.contact-item.has-unread { + background: rgba(239, 68, 68, 0.1); +} + +.contact-item.has-unread .contact-name { + font-weight: 600; + color: #f1f5f9; +} + +/* Chat Windows Container */ +.chat-windows-container { + position: fixed; + bottom: 0; + right: 0; + left: 0; + top: 0; + z-index: 9990; + pointer-events: none; +} + +.chat-window-wrapper { + position: fixed; + bottom: 0; + pointer-events: auto; + z-index: 9990; +} + +/* Light theme */ +body[data-theme="light"] .chat-tab { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.95), rgba(99, 102, 241, 0.9)); +} + +body[data-theme="light"] .contacts-float-btn { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.95), rgba(99, 102, 241, 0.9)); +} + +body[data-theme="light"] .contacts-dropdown { + background: rgba(255, 255, 255, 0.98); + border-color: rgba(148, 163, 184, 0.4); +} + +body[data-theme="light"] .contacts-header { + color: #1e293b; + border-color: rgba(148, 163, 184, 0.3); +} + +body[data-theme="light"] .contact-item:hover { + background: rgba(59, 130, 246, 0.1); +} + +body[data-theme="light"] .contact-name { + color: #334155; +} + +body[data-theme="light"] .contacts-empty { + color: #64748b; +} + +/* Mobile responsive */ +@media (max-width: 640px) { + .chat-bottom-bar { + right: 8px; + left: 8px; + gap: 6px; + /* Handle virtual keyboard */ + bottom: max(16px, env(safe-area-inset-bottom, 16px)); + } + + .chat-tab { + padding: 5px 8px 5px 5px; + } + + .chat-tab-name { + max-width: 60px; + font-size: 12px; + } + + .chat-tab-avatar { + width: 24px; + height: 24px; + font-size: 10px; + } + + .contacts-float-btn .contact-label { + display: none; + } + + .contacts-float-btn { + padding: 12px; + } + + .contacts-dropdown { + right: 8px; + left: 8px; + width: auto; + bottom: 70px; + } + + .chat-windows-container { + left: 0; + right: 0; + } + + .chat-window-wrapper { + left: 0 !important; + right: 0 !important; + width: 100%; + /* Smooth transition for keyboard adjustment */ + transition: bottom 0.15s ease-out; + } + + /* Hide bottom bar when chat is open to avoid z-index conflicts */ + .chat-windows-container:has(.chat-window:not(.chat-window--minimized)) ~ .chat-bottom-bar { + z-index: 9980; + } +} diff --git a/src/components/Chat/ChatContainer.jsx b/src/components/Chat/ChatContainer.jsx new file mode 100644 index 0000000..f456bfa --- /dev/null +++ b/src/components/Chat/ChatContainer.jsx @@ -0,0 +1,532 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "../../auth/AuthContext"; +import { fetchFriends } from "../../api/client"; +import { useChat } from "./useChat"; +import ChatWindow from "./ChatWindow"; +import "./ChatContainer.css"; + +const ACTIVE_CHATS_KEY = "sociowire:activeChats"; + +// Play notification sound using Web Audio API +function playNotificationSound() { + try { + const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioCtx.createOscillator(); + const gainNode = audioCtx.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioCtx.destination); + + oscillator.frequency.value = 800; + oscillator.type = "sine"; + gainNode.gain.value = 0.3; + + oscillator.start(); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.2); + oscillator.stop(audioCtx.currentTime + 0.2); + } catch (e) { + // Audio not supported + } +} + +export default function ChatContainer() { + const { t } = useTranslation(); + const { authenticated, loading: authLoading, username } = useAuth(); + + const [friends, setFriends] = useState([]); + const [showContacts, setShowContacts] = useState(false); + const [activeChats, setActiveChats] = useState([]); // [{ username, avatar }] + const [minimizedChats, setMinimizedChats] = useState({}); // { username: boolean } + const [unreadCounts, setUnreadCounts] = useState({}); // { username: number } + + const notificationPermissionRef = useRef("default"); + const minimizedChatsRef = useRef(minimizedChats); + const activeChatsRef = useRef(activeChats); + + // Keep refs updated + useEffect(() => { + minimizedChatsRef.current = minimizedChats; + }, [minimizedChats]); + + useEffect(() => { + activeChatsRef.current = activeChats; + }, [activeChats]); + + // Lock body scroll on mobile when chat is open (prevents keyboard scroll issues) + useEffect(() => { + const hasOpenChat = activeChats.some((chat) => !minimizedChats[chat.username]); + const isMobile = window.innerWidth <= 640; + + if (hasOpenChat && isMobile) { + // Save current scroll position + const scrollY = window.scrollY; + document.body.style.position = 'fixed'; + document.body.style.top = `-${scrollY}px`; + document.body.style.left = '0'; + document.body.style.right = '0'; + document.body.dataset.chatScrollY = scrollY; + } else if (document.body.style.position === 'fixed') { + // Restore scroll position + const scrollY = document.body.dataset.chatScrollY || 0; + document.body.style.position = ''; + document.body.style.top = ''; + document.body.style.left = ''; + document.body.style.right = ''; + window.scrollTo(0, parseInt(scrollY, 10)); + } + + return () => { + if (document.body.style.position === 'fixed') { + const scrollY = document.body.dataset.chatScrollY || 0; + document.body.style.position = ''; + document.body.style.top = ''; + document.body.style.left = ''; + document.body.style.right = ''; + window.scrollTo(0, parseInt(scrollY, 10)); + } + }; + }, [activeChats, minimizedChats]); + + // Request notification permission + useEffect(() => { + if ("Notification" in window) { + notificationPermissionRef.current = Notification.permission; + if (Notification.permission === "default") { + Notification.requestPermission().then((permission) => { + notificationPermissionRef.current = permission; + }); + } + } + }, []); + + // Handle new incoming message + const handleNewMessage = useCallback((msg) => { + const senderName = msg.sender_name; + + // Check if chat is open and not minimized + const isChatOpen = activeChatsRef.current.some((c) => c.username === senderName); + const isChatMinimized = minimizedChatsRef.current[senderName]; + const isDocumentFocused = document.hasFocus(); + + // Update unread count if chat is not visible + if (!isChatOpen || isChatMinimized || !isDocumentFocused) { + setUnreadCounts((prev) => ({ + ...prev, + [senderName]: (prev[senderName] || 0) + 1, + })); + } + + // Show notification if: + // - Chat is not open, or is minimized, or document is not focused + // - Notification permission is granted + const shouldNotify = !isChatOpen || isChatMinimized || !isDocumentFocused; + + if (shouldNotify) { + // Play notification sound + playNotificationSound(); + + // Show browser notification + if (notificationPermissionRef.current === "granted") { + const notification = new Notification(`${senderName}`, { + body: msg.content?.substring(0, 100) || "New message", + icon: "/favicon.ico", + tag: `chat-${senderName}`, + silent: true, // We play our own sound + }); + + notification.onclick = () => { + window.focus(); + // Open or unminimize the chat + const friend = { username: senderName }; + if (!activeChatsRef.current.some((c) => c.username === senderName)) { + setActiveChats((prev) => [...prev, friend]); + } + setMinimizedChats((prev) => ({ ...prev, [senderName]: false })); + notification.close(); + }; + } + } + }, []); + + // Chat hook + const { + connected, + messages, + typing, + presence, + conversations, + sendMessage, + sendTyping, + markAsRead, + fetchMessages, + fetchConversations, + } = useChat({ + username, + enabled: authenticated, + onNewMessage: handleNewMessage, + }); + + // Load friends when authenticated + useEffect(() => { + if (!authenticated) { + setFriends([]); + return; + } + + const loadFriends = async () => { + const res = await fetchFriends(); + if (res?.ok && res.friends) { + setFriends(res.friends); + } + }; + + loadFriends(); + }, [authenticated]); + + // Load conversations to get initial unread counts + useEffect(() => { + if (!authenticated || !connected) return; + + const loadConversations = async () => { + const convs = await fetchConversations(); + if (convs && convs.length > 0) { + const newUnread = {}; + convs.forEach((c) => { + if (c.unread_count > 0) { + newUnread[c.username] = c.unread_count; + } + }); + if (Object.keys(newUnread).length > 0) { + setUnreadCounts((prev) => ({ ...prev, ...newUnread })); + } + } + }; + + loadConversations(); + }, [authenticated, connected, fetchConversations]); + + // Load active chats from localStorage + useEffect(() => { + try { + const saved = localStorage.getItem(ACTIVE_CHATS_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) { + setActiveChats(parsed); + } + } + } catch {} + }, []); + + // Save active chats to localStorage + useEffect(() => { + try { + localStorage.setItem(ACTIVE_CHATS_KEY, JSON.stringify(activeChats)); + } catch {} + }, [activeChats]); + + // Refresh messages and unread counts when page becomes visible (mobile resume) + useEffect(() => { + const handleVisibilityChange = async () => { + if (document.visibilityState === 'visible' && authenticated) { + console.log('[Chat] Page visible, refreshing data...'); + + // Refresh messages for active chats + if (activeChats.length > 0) { + activeChats.forEach((chat) => { + fetchMessages(chat.username); + }); + } + + // Refresh conversations to update unread counts/badges + const convs = await fetchConversations(); + if (convs && convs.length > 0) { + const newUnread = {}; + convs.forEach((c) => { + if (c.unread_count > 0) { + newUnread[c.username] = c.unread_count; + } + }); + setUnreadCounts((prev) => { + // Merge but don't overwrite zeros (already read in this session) + const merged = { ...prev }; + Object.entries(newUnread).forEach(([user, count]) => { + // Only update if we don't have a local zero (user already read it) + if (merged[user] === undefined || merged[user] > 0) { + merged[user] = count; + } + }); + return merged; + }); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [activeChats, fetchMessages, fetchConversations, authenticated]); + + + // Open chat with a friend + const openChat = useCallback( + (friend) => { + if (!authenticated) { + // Trigger auth modal + window.dispatchEvent( + new CustomEvent("sociowire:auth-required", { + detail: { + message: t("chat.signInToChat", "Sign in to chat with friends"), + mode: "login", + }, + }) + ); + setShowContacts(false); + return; + } + + // Save scroll position before any state changes + const scrollY = window.scrollY; + + const friendUsername = friend.username || friend.name; + const chatData = { + username: friendUsername, + avatar: friend.avatar_url || friend.avatar, + }; + + // Check if already open + if (!activeChats.some((c) => c.username === chatData.username)) { + setActiveChats((prev) => [...prev, chatData]); + + // Fetch message history + fetchMessages(friendUsername); + } + + // Unminimize if minimized + setMinimizedChats((prev) => ({ ...prev, [friendUsername]: false })); + // Clear unread count and mark as read + setUnreadCounts((prev) => ({ ...prev, [friendUsername]: 0 })); + markAsRead(friendUsername); + setShowContacts(false); + + // Restore scroll position after React updates + requestAnimationFrame(() => { + window.scrollTo(0, scrollY); + }); + }, + [authenticated, activeChats, fetchMessages, markAsRead, t] + ); + + // Close a chat + const closeChat = useCallback((chatUsername) => { + // Save scroll position before closing + const scrollY = window.scrollY; + + setActiveChats((prev) => prev.filter((c) => c.username !== chatUsername)); + setMinimizedChats((prev) => { + const next = { ...prev }; + delete next[chatUsername]; + return next; + }); + + // Restore scroll position after React updates + requestAnimationFrame(() => { + window.scrollTo(0, scrollY); + }); + }, []); + + // Toggle minimize + const toggleMinimize = useCallback((chatUsername) => { + const scrollY = window.scrollY; + + setMinimizedChats((prev) => { + const wasMinimized = prev[chatUsername]; + // Clear unread count and mark as read when unminimizing + if (wasMinimized) { + setUnreadCounts((uc) => ({ ...uc, [chatUsername]: 0 })); + markAsRead(chatUsername); + } + return { ...prev, [chatUsername]: !wasMinimized }; + }); + + requestAnimationFrame(() => { + window.scrollTo(0, scrollY); + }); + }, [markAsRead]); + + // Handle send message + const handleSend = useCallback( + (chatUsername) => (content) => { + sendMessage(chatUsername, content); + }, + [sendMessage] + ); + + // Handle typing + const handleTyping = useCallback( + (chatUsername) => (isTyping) => { + sendTyping(chatUsername, isTyping); + }, + [sendTyping] + ); + + // Handle contacts button click + const handleContactsClick = () => { + if (!authenticated) { + // Trigger auth modal + window.dispatchEvent( + new CustomEvent("sociowire:auth-required", { + detail: { + message: t("chat.signInToChat", "Sign in to chat with friends"), + mode: "login", + }, + }) + ); + return; + } + + setShowContacts(!showContacts); + }; + + // Calculate total unread count + const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0); + + // Use portal to render chat outside of normal DOM hierarchy + // This ensures chat is completely independent and always on top + return createPortal( + <> + {/* Bottom Bar: Active Chat Tabs + Contacts Button */} +
+ {/* Active Chat Tabs (minimized chats) */} + {activeChats.filter(chat => minimizedChats[chat.username]).map((chat) => { + const unread = unreadCounts[chat.username] || 0; + return ( + + + ); + })} + + {/* Contacts Button */} + +
+ + {/* Contacts Dropdown */} + {showContacts && ( +
+
+ {t("common.contacts")} + +
+ + {!authenticated ? ( +
+ {t("friends.signInToSee")} +
+ ) : friends.length === 0 ? ( +
+ {t("friends.noFriends")} +
+ ) : ( +
+ {friends.map((friend) => { + const friendUsername = friend.username || friend.name; + const avatar = friend.avatar_url || friend.avatar; + const isOnline = presence[friendUsername] || false; + const unreadCount = unreadCounts[friendUsername] || 0; + + return ( +
0 ? " has-unread" : "")} + onClick={() => openChat(friend)} + > +
+ {avatar ? ( + {friendUsername} + ) : ( + {(friendUsername || "?")[0].toUpperCase()} + )} +
+ {friendUsername} + {unreadCount > 0 && ( + {unreadCount} + )} + {isOnline && } +
+ ); + })} +
+ )} +
+ )} + + {/* Active Chat Windows (only non-minimized) */} +
+ {activeChats + .filter((chat) => !minimizedChats[chat.username]) + .map((chat, index) => ( +
+ closeChat(chat.username)} + onMinimize={() => toggleMinimize(chat.username)} + /> +
+ ))} +
+ , + document.body + ); +} diff --git a/src/components/Chat/ChatWindow.css b/src/components/Chat/ChatWindow.css new file mode 100644 index 0000000..866dc9b --- /dev/null +++ b/src/components/Chat/ChatWindow.css @@ -0,0 +1,324 @@ +.chat-window { + width: 320px; + height: 420px; + background: rgba(15, 23, 42, 0.95); + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 12px 12px 0 0; + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + overflow: hidden; + backdrop-filter: blur(12px); +} + +.chat-window--minimized { + height: auto; + cursor: pointer; +} + +.chat-window--minimized:hover { + background: rgba(30, 41, 59, 0.95); +} + +/* Header */ +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: linear-gradient(135deg, rgba(30, 58, 138, 0.8), rgba(59, 130, 246, 0.6)); + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +.chat-user { + display: flex; + align-items: center; + gap: 10px; +} + +.chat-avatar-sm { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, #3b82f6, #8b5cf6); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 14px; + position: relative; + overflow: hidden; +} + +.chat-avatar-sm img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.chat-avatar-sm.online::after { + content: ""; + position: absolute; + bottom: 0; + right: 0; + width: 10px; + height: 10px; + background: #22c55e; + border: 2px solid rgba(15, 23, 42, 0.95); + border-radius: 50%; +} + +.chat-user-info { + display: flex; + flex-direction: column; +} + +.chat-username { + font-weight: 600; + font-size: 14px; + color: #f1f5f9; +} + +.chat-status { + font-size: 11px; + color: #22c55e; +} + +.chat-actions { + display: flex; + gap: 4px; +} + +.chat-minimize, +.chat-close { + width: 26px; + height: 26px; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: #e2e8f0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: background 0.2s; +} + +.chat-minimize:hover, +.chat-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Messages */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.chat-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: rgba(148, 163, 184, 0.7); + font-size: 13px; +} + +.chat-msg { + max-width: 80%; + padding: 8px 12px; + border-radius: 16px; + background: rgba(59, 130, 246, 0.15); + color: #e2e8f0; + font-size: 13px; + line-height: 1.4; + align-self: flex-start; +} + +.chat-msg--mine { + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + align-self: flex-end; +} + +.chat-msg-content { + word-break: break-word; +} + +.chat-msg-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + margin-top: 4px; +} + +.chat-msg-time { + font-size: 10px; + color: rgba(148, 163, 184, 0.7); +} + +.chat-msg--mine .chat-msg-time { + color: rgba(255, 255, 255, 0.7); +} + +.chat-msg-status { + font-size: 10px; + color: rgba(255, 255, 255, 0.6); +} + +.chat-msg-status i { + font-size: 10px; +} + +.chat-msg-status i.read { + color: #22c55e; +} + +/* Typing indicator */ +.chat-typing { + display: flex; + gap: 4px; + padding: 8px 12px; + align-self: flex-start; +} + +.chat-typing-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(148, 163, 184, 0.5); + animation: typingBounce 1.4s infinite ease-in-out both; +} + +.chat-typing-dot:nth-child(1) { + animation-delay: 0s; +} +.chat-typing-dot:nth-child(2) { + animation-delay: 0.2s; +} +.chat-typing-dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typingBounce { + 0%, + 80%, + 100% { + transform: scale(0.6); + opacity: 0.4; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* Input */ +.chat-input-area { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.6); +} + +.chat-input { + flex: 1; + padding: 8px 14px; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 20px; + background: rgba(30, 41, 59, 0.8); + color: #f1f5f9; + font-size: 13px; + outline: none; +} + +.chat-input::placeholder { + color: rgba(148, 163, 184, 0.6); +} + +.chat-input:focus { + border-color: #3b82f6; +} + +.chat-send { + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: transform 0.2s, opacity 0.2s; +} + +.chat-send:hover:not(:disabled) { + transform: scale(1.05); +} + +.chat-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Light theme */ +body[data-theme="light"] .chat-window { + background: rgba(255, 255, 255, 0.98); + border-color: rgba(148, 163, 184, 0.4); +} + +body[data-theme="light"] .chat-header { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(99, 102, 241, 0.8)); +} + +body[data-theme="light"] .chat-username { + color: #f1f5f9; +} + +body[data-theme="light"] .chat-messages { + background: #f8fafc; +} + +body[data-theme="light"] .chat-msg { + background: rgba(59, 130, 246, 0.1); + color: #1e293b; +} + +body[data-theme="light"] .chat-input-area { + background: rgba(255, 255, 255, 0.9); +} + +body[data-theme="light"] .chat-input { + background: #f1f5f9; + color: #1e293b; + border-color: rgba(148, 163, 184, 0.4); +} + +/* Responsive */ +@media (max-width: 640px) { + .chat-window { + width: 100%; + max-width: 100vw; + height: 50vh; + height: 50dvh; /* Dynamic viewport height - accounts for keyboard */ + max-height: calc(100vh - 60px); + max-height: calc(100dvh - 60px); + border-radius: 16px 16px 0 0; + } + + .chat-input-area { + padding-bottom: max(10px, env(safe-area-inset-bottom, 10px)); + } +} diff --git a/src/components/Chat/ChatWindow.jsx b/src/components/Chat/ChatWindow.jsx new file mode 100644 index 0000000..c69b672 --- /dev/null +++ b/src/components/Chat/ChatWindow.jsx @@ -0,0 +1,218 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { formatChatTime } from "../../utils/timezone"; +import "./ChatWindow.css"; + +export default function ChatWindow({ + recipientID, + recipientName, + recipientAvatar, + messages = [], + typing = false, + online = false, + onSend, + onTyping, + onClose, + onMinimize, + minimized = false, +}) { + const { t } = useTranslation(); + const [input, setInput] = useState(""); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const typingTimeout = useRef(null); + + // Auto-scroll to bottom on new messages + useEffect(() => { + if (!minimized && messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [messages, minimized]); + + // Focus input when opening (without scrolling the page) + useEffect(() => { + if (!minimized && inputRef.current) { + // Use preventScroll to avoid page scroll when focusing + inputRef.current.focus({ preventScroll: true }); + } + }, [minimized]); + + const handleSend = () => { + const text = input.trim(); + if (!text) return; + + onSend?.(text); + setInput(""); + + // Clear typing indicator + if (typingTimeout.current) { + clearTimeout(typingTimeout.current); + } + onTyping?.(false); + }; + + const handleKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleInputChange = (e) => { + setInput(e.target.value); + + // Send typing indicator + onTyping?.(true); + + if (typingTimeout.current) { + clearTimeout(typingTimeout.current); + } + + typingTimeout.current = setTimeout(() => { + onTyping?.(false); + }, 2000); + }; + + // Use timezone-aware formatting + const formatTime = formatChatTime; + + if (minimized) { + return ( +
+
+
+
+ {recipientAvatar ? ( + {recipientName} + ) : ( + {(recipientName || "?")[0].toUpperCase()} + )} +
+ {recipientName} +
+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ {recipientAvatar ? ( + {recipientName} + ) : ( + {(recipientName || "?")[0].toUpperCase()} + )} +
+
+ {recipientName} + + {online ? t("common.online") : ""} + +
+
+
+ + +
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+ {t("chat.startConversation", "Start a conversation")} +
+ ) : ( + messages.map((msg, idx) => { + // Compare by username (sender_name), case-insensitive + const isMine = msg.sender_name?.toLowerCase() !== recipientName?.toLowerCase(); + // Determine message status for sent messages + const isSending = msg.id?.toString().startsWith("temp-"); + const isRead = msg.read_at != null; + const isSent = !isSending && msg.id; + + return ( +
+
{msg.content}
+
+ {formatTime(msg.created_at)} + {isMine && ( + + {isSending ? ( + + ) : isRead ? ( + + ) : isSent ? ( + + ) : null} + + )} +
+
+ ); + }) + )} + {typing && ( +
+ + + +
+ )} +
+
+ + {/* Input */} +
+ + +
+
+ ); +} diff --git a/src/components/Chat/cryptoUtils.js b/src/components/Chat/cryptoUtils.js new file mode 100644 index 0000000..5ecadd9 --- /dev/null +++ b/src/components/Chat/cryptoUtils.js @@ -0,0 +1,192 @@ +/** + * End-to-End Encryption utilities using Web Crypto API + * Uses ECDH for key exchange and AES-GCM for message encryption + */ + +const STORAGE_KEY = "sociowire:e2e:keypair"; +const PEER_KEYS_KEY = "sociowire:e2e:peerKeys"; + +// Generate or retrieve the user's key pair +export async function getOrCreateKeyPair() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const { publicKey, privateKey } = JSON.parse(stored); + return { + publicKey: await importPublicKey(publicKey), + privateKey: await importPrivateKey(privateKey), + publicKeyExport: publicKey, + }; + } + } catch (e) { + console.warn("[E2E] Failed to load stored keys, generating new ones", e); + } + + // Generate new ECDH key pair + const keyPair = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"] + ); + + // Export keys for storage + const publicKeyExport = await exportPublicKey(keyPair.publicKey); + const privateKeyExport = await exportPrivateKey(keyPair.privateKey); + + // Store in localStorage + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + publicKey: publicKeyExport, + privateKey: privateKeyExport, + })); + + return { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + publicKeyExport, + }; +} + +// Export public key to base64 +async function exportPublicKey(key) { + const exported = await crypto.subtle.exportKey("spki", key); + return btoa(String.fromCharCode(...new Uint8Array(exported))); +} + +// Export private key to base64 +async function exportPrivateKey(key) { + const exported = await crypto.subtle.exportKey("pkcs8", key); + return btoa(String.fromCharCode(...new Uint8Array(exported))); +} + +// Import public key from base64 +async function importPublicKey(base64) { + const binary = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); + return crypto.subtle.importKey( + "spki", + binary, + { name: "ECDH", namedCurve: "P-256" }, + true, + [] + ); +} + +// Import private key from base64 +async function importPrivateKey(base64) { + const binary = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); + return crypto.subtle.importKey( + "pkcs8", + binary, + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"] + ); +} + +// Store peer's public key +export function storePeerPublicKey(username, publicKeyBase64) { + try { + const peerKeys = JSON.parse(localStorage.getItem(PEER_KEYS_KEY) || "{}"); + peerKeys[username] = publicKeyBase64; + localStorage.setItem(PEER_KEYS_KEY, JSON.stringify(peerKeys)); + } catch (e) { + console.warn("[E2E] Failed to store peer key", e); + } +} + +// Get peer's public key +export function getPeerPublicKey(username) { + try { + const peerKeys = JSON.parse(localStorage.getItem(PEER_KEYS_KEY) || "{}"); + return peerKeys[username] || null; + } catch (e) { + return null; + } +} + +// Derive shared secret from own private key and peer's public key +async function deriveSharedKey(privateKey, peerPublicKeyBase64) { + const peerPublicKey = await importPublicKey(peerPublicKeyBase64); + + // Derive shared bits using ECDH + const sharedBits = await crypto.subtle.deriveBits( + { name: "ECDH", public: peerPublicKey }, + privateKey, + 256 + ); + + // Import as AES-GCM key + return crypto.subtle.importKey( + "raw", + sharedBits, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +// Encrypt a message +export async function encryptMessage(privateKey, peerPublicKeyBase64, plaintext) { + try { + const sharedKey = await deriveSharedKey(privateKey, peerPublicKeyBase64); + + // Generate random IV + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Encrypt + const encoder = new TextEncoder(); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + sharedKey, + encoder.encode(plaintext) + ); + + // Combine IV + ciphertext and encode as base64 + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + + return "e2e:" + btoa(String.fromCharCode(...combined)); + } catch (e) { + console.error("[E2E] Encryption failed", e); + return plaintext; // Fallback to plaintext + } +} + +// Decrypt a message +export async function decryptMessage(privateKey, peerPublicKeyBase64, ciphertext) { + try { + // Check if message is encrypted + if (!ciphertext.startsWith("e2e:")) { + return ciphertext; // Not encrypted + } + + const sharedKey = await deriveSharedKey(privateKey, peerPublicKeyBase64); + + // Decode base64 + const combined = Uint8Array.from(atob(ciphertext.slice(4)), c => c.charCodeAt(0)); + + // Extract IV and ciphertext + const iv = combined.slice(0, 12); + const encrypted = combined.slice(12); + + // Decrypt + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + sharedKey, + encrypted + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (e) { + console.error("[E2E] Decryption failed", e); + return "[Encrypted message - decryption failed]"; + } +} + +// Check if E2E is available (Web Crypto API support) +export function isE2EAvailable() { + return typeof crypto !== "undefined" && + typeof crypto.subtle !== "undefined" && + typeof crypto.subtle.generateKey === "function"; +} diff --git a/src/components/Chat/useChat.js b/src/components/Chat/useChat.js new file mode 100644 index 0000000..2ed9b21 --- /dev/null +++ b/src/components/Chat/useChat.js @@ -0,0 +1,382 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { + getOrCreateKeyPair, + storePeerPublicKey, + getPeerPublicKey, + encryptMessage, + decryptMessage, + isE2EAvailable, +} from "./cryptoUtils"; + +const WS_BASE = import.meta.env.VITE_CHAT_WS_URL || + (window.location.protocol === "https:" ? "wss://" : "ws://") + + window.location.host + "/chat/ws"; + +const API_BASE = import.meta.env.VITE_CHAT_API_URL || "/chat/api"; + +export function useChat({ username, enabled = true, onNewMessage }) { + const [connected, setConnected] = useState(false); + const [messages, setMessages] = useState({}); // { recipientUsername: Message[] } + const [typing, setTyping] = useState({}); // { username: boolean } + const [presence, setPresence] = useState({}); // { username: boolean } + const [conversations, setConversations] = useState([]); + const [e2eReady, setE2eReady] = useState(false); + + const wsRef = useRef(null); + const reconnectTimer = useRef(null); + const reconnectAttempts = useRef(0); + const keyPairRef = useRef(null); + const usernameRef = useRef(username); + const enabledRef = useRef(enabled); + const onNewMessageRef = useRef(onNewMessage); + + // Keep refs updated + useEffect(() => { + usernameRef.current = username; + }, [username]); + + useEffect(() => { + enabledRef.current = enabled; + }, [enabled]); + + useEffect(() => { + onNewMessageRef.current = onNewMessage; + }, [onNewMessage]); + + // Initialize E2E encryption keys + useEffect(() => { + if (!isE2EAvailable()) { + console.warn("[E2E] Web Crypto API not available"); + return; + } + + getOrCreateKeyPair().then((kp) => { + keyPairRef.current = kp; + setE2eReady(true); + console.log("[E2E] Key pair ready"); + }).catch((err) => { + console.error("[E2E] Failed to initialize keys", err); + }); + }, []); + + // Send public key to a peer for key exchange + const sendPublicKey = useCallback((recipientUsername) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + if (!keyPairRef.current?.publicKeyExport) return; + + const msg = { + type: "key_exchange", + payload: { + recipient_username: recipientUsername, + public_key: keyPairRef.current.publicKeyExport, + }, + }; + wsRef.current.send(JSON.stringify(msg)); + }, []); + + // Handle incoming WebSocket messages (uses ref to avoid dependency changes) + const handleMessage = useCallback((data) => { + const currentUsername = usernameRef.current; + + // Parse payload if it's a string (backwards compat), otherwise use as-is + const parsePayload = (payload) => { + if (typeof payload === "string") { + try { + return JSON.parse(payload); + } catch { + return payload; + } + } + return payload; + }; + + switch (data.type) { + case "message": { + const msg = parsePayload(data.payload); + // Case-insensitive comparison for username matching + const isMySent = msg.sender_name?.toLowerCase() === currentUsername?.toLowerCase(); + const otherUser = isMySent ? msg.recipient_name : msg.sender_name; + const isIncoming = !isMySent; + + console.log("[Chat] Received message from", msg.sender_name, "to", msg.recipient_name); + + setMessages((prev) => { + const existing = prev[otherUser] || []; + // Avoid duplicates (check by id or by temp-id prefix) + if (existing.some((m) => m.id === msg.id || (m.id?.toString().startsWith("temp-") && m.content === msg.content && m.sender_name === msg.sender_name))) { + // Replace temp message with real one + return { + ...prev, + [otherUser]: existing.map((m) => + m.id?.toString().startsWith("temp-") && m.content === msg.content && m.sender_name === msg.sender_name + ? msg + : m + ), + }; + } + return { ...prev, [otherUser]: [...existing, msg] }; + }); + + // Notify parent component of new incoming message + if (isIncoming && onNewMessageRef.current) { + onNewMessageRef.current(msg); + } + break; + } + + case "typing": { + const payload = parsePayload(data.payload); + setTyping((prev) => ({ + ...prev, + [payload.username]: payload.is_typing, + })); + + // Clear typing indicator after 3 seconds + setTimeout(() => { + setTyping((prev) => ({ ...prev, [payload.username]: false })); + }, 3000); + break; + } + + case "presence": { + const payload = parsePayload(data.payload); + setPresence((prev) => ({ + ...prev, + [payload.username]: payload.online, + })); + break; + } + + default: + console.log("[Chat] Unknown message type", data.type); + } + }, []); // No dependencies - uses refs + + // Connect to WebSocket (uses refs to avoid dependency changes) + const connect = useCallback(() => { + const currentUsername = usernameRef.current; + const currentEnabled = enabledRef.current; + + if (!currentEnabled || !currentUsername) return; + + // Don't reconnect if already connected + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + console.log("[Chat] Already connected"); + return; + } + + const wsUrl = `${WS_BASE}?username=${encodeURIComponent(currentUsername)}`; + console.log("[Chat] Connecting to", wsUrl); + + try { + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + console.log("[Chat] WebSocket connected as", currentUsername); + setConnected(true); + reconnectAttempts.current = 0; + }; + + ws.onclose = (e) => { + console.log("[Chat] WebSocket closed", e.code, e.reason); + setConnected(false); + wsRef.current = null; + + // Reconnect with exponential backoff (check ref values) + if (enabledRef.current && reconnectAttempts.current < 10) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000); + reconnectAttempts.current++; + console.log("[Chat] Reconnecting in", delay, "ms (attempt", reconnectAttempts.current, ")"); + reconnectTimer.current = setTimeout(connect, delay); + } + }; + + ws.onerror = (e) => { + console.error("[Chat] WebSocket error", e); + }; + + ws.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + handleMessage(data); + } catch (err) { + console.error("[Chat] Parse error", err); + } + }; + + wsRef.current = ws; + } catch (err) { + console.error("[Chat] Connection error", err); + } + }, [handleMessage]); // Only depends on stable handleMessage + + // Send a chat message + const sendMessage = useCallback((recipientUsername, content) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + console.error("[Chat] WebSocket not connected"); + return false; + } + + const currentUsername = usernameRef.current; + + // Optimistic update - add message to local state immediately + const optimisticMsg = { + id: `temp-${Date.now()}`, + sender_name: currentUsername, + recipient_name: recipientUsername, + content: content, + created_at: new Date().toISOString(), + }; + + setMessages((prev) => { + const existing = prev[recipientUsername] || []; + return { ...prev, [recipientUsername]: [...existing, optimisticMsg] }; + }); + + const msg = { + type: "message", + payload: { + recipient_username: recipientUsername, + content: content, + }, + }; + + wsRef.current.send(JSON.stringify(msg)); + return true; + }, []); + + // Send typing indicator + const sendTyping = useCallback((recipientUsername, isTyping) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + + const msg = { + type: "typing", + payload: { + recipient_username: recipientUsername, + is_typing: isTyping, + }, + }; + + wsRef.current.send(JSON.stringify(msg)); + }, []); + + // Mark messages as read + const markAsRead = useCallback((senderUsername) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + + const msg = { + type: "read", + payload: { sender_username: senderUsername }, + }; + + wsRef.current.send(JSON.stringify(msg)); + }, []); + + // Fetch message history + const fetchMessages = useCallback(async (otherUsername) => { + if (!username || !otherUsername) return []; + + try { + const res = await fetch(`${API_BASE}/messages?username=${encodeURIComponent(username)}&other=${encodeURIComponent(otherUsername)}`); + const data = await res.json(); + + if (data.ok && data.messages) { + setMessages((prev) => ({ ...prev, [otherUsername]: data.messages })); + return data.messages; + } + } catch (err) { + console.error("[Chat] Fetch messages error", err); + } + return []; + }, [username]); + + // Fetch conversations list + const fetchConversations = useCallback(async () => { + if (!username) return []; + + try { + const res = await fetch(`${API_BASE}/conversations?username=${encodeURIComponent(username)}`); + const data = await res.json(); + + if (data.ok && data.conversations) { + setConversations(data.conversations); + + // Update presence from conversations + const newPresence = {}; + data.conversations.forEach((c) => { + newPresence[c.username] = c.online; + }); + setPresence((prev) => ({ ...prev, ...newPresence })); + + return data.conversations; + } + } catch (err) { + console.error("[Chat] Fetch conversations error", err); + } + return []; + }, [username]); + + // Connect on mount / when enabled or username changes + useEffect(() => { + if (enabled && username) { + // Only connect if not already connected + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + connect(); + } + } else { + // Disconnect if disabled or no username + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + } + }, [enabled, username, connect]); + + // Reconnect when page becomes visible (mobile app resume) + useEffect(() => { + if (!enabled || !username) return; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + console.log('[Chat] Page visible, checking connection...'); + // Check if WebSocket is disconnected or in bad state + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + console.log('[Chat] Reconnecting after visibility change...'); + reconnectAttempts.current = 0; // Reset attempts + connect(); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enabled, username, connect]); + + // Cleanup on unmount only + useEffect(() => { + return () => { + if (reconnectTimer.current) { + clearTimeout(reconnectTimer.current); + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, []); + + return { + connected, + messages, + typing, + presence, + conversations, + sendMessage, + sendTyping, + markAsRead, + fetchMessages, + fetchConversations, + }; +} diff --git a/src/components/Filters/FilterButtons.jsx b/src/components/Filters/FilterButtons.jsx index b5710d7..a160f99 100644 --- a/src/components/Filters/FilterButtons.jsx +++ b/src/components/Filters/FilterButtons.jsx @@ -1,12 +1,15 @@ import React from "react"; +import { useTranslation } from "react-i18next"; export default function FilterButtons({ active, onSelect }) { + const { t } = useTranslation(); + const buttons = [ - { code: "All", label: "All", icon: "fa-solid fa-globe" }, - { code: "News", label: "News", icon: "fa-solid fa-newspaper" }, - { code: "Friends", label: "Friends", icon: "fa-solid fa-user-group" }, - { code: "Events", label: "Events", icon: "fa-solid fa-calendar-days" }, - { code: "Market", label: "Market", icon: "fa-solid fa-chart-line" }, + { code: "All", labelKey: "filters.all", icon: "fa-solid fa-globe" }, + { code: "News", labelKey: "filters.news", icon: "fa-solid fa-newspaper" }, + { code: "Friends", labelKey: "filters.friends", icon: "fa-solid fa-user-group" }, + { code: "Events", labelKey: "filters.events", icon: "fa-solid fa-calendar-days" }, + { code: "Market", labelKey: "filters.market", icon: "fa-solid fa-chart-line" }, ]; return ( @@ -21,7 +24,7 @@ export default function FilterButtons({ active, onSelect }) {
-
{btn.label}
+
{t(btn.labelKey)}
))}
diff --git a/src/components/Filters/TimeFilterButtons.jsx b/src/components/Filters/TimeFilterButtons.jsx index 695b09b..157ae31 100644 --- a/src/components/Filters/TimeFilterButtons.jsx +++ b/src/components/Filters/TimeFilterButtons.jsx @@ -1,11 +1,14 @@ import React from "react"; +import { useTranslation } from "react-i18next"; export default function TimeFilterButtons({ activeHours, onSelect }) { + const { t } = useTranslation(); + const buttons = [ - { hours: 3, label: "Now", icon: "fa-solid fa-bolt" }, - { hours: 24, label: "Today", icon: "fa-solid fa-sun" }, - { hours: 96, label: "4Days", icon: "fa-solid fa-calendar-days" }, - { hours: 336, label: "Old", icon: "fa-solid fa-hourglass-half" }, + { hours: 3, labelKey: "time_filter.now", icon: "fa-solid fa-bolt" }, + { hours: 24, labelKey: "time_filter.today", icon: "fa-solid fa-sun" }, + { hours: 96, labelKey: "time_filter.recents", icon: "fa-solid fa-calendar-days" }, + { hours: 336, labelKey: "time_filter.old", icon: "fa-solid fa-hourglass-half" }, ]; return ( @@ -20,7 +23,7 @@ export default function TimeFilterButtons({ activeHours, onSelect }) {
-
{btn.label}
+
{t(btn.labelKey)}
))}
diff --git a/src/components/Friends/Friends.css b/src/components/Friends/Friends.css index 896cd6b..e65079f 100644 --- a/src/components/Friends/Friends.css +++ b/src/components/Friends/Friends.css @@ -127,6 +127,42 @@ display: flex; align-items: center; justify-content: center; + overflow: hidden; + flex-shrink: 0; +} + +.friend-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.friend-info-text { + display: flex; + flex-direction: column; + gap: .1rem; + min-width: 0; +} + +.friend-request-direction { + font-size: .65rem; + color: #64748b; +} + +.friend-item-sent { + border-color: rgba(251, 191, 36, 0.3); +} + +.friend-item-sent .friend-request-direction { + color: #fbbf24; +} + +.friend-item-received { + border-color: rgba(56, 189, 248, 0.4); +} + +.friend-item-received .friend-request-direction { + color: #7dd3fc; } .friend-name { @@ -180,6 +216,15 @@ border-color: rgba(248,113,113,0.6); } +.friend-action-cancel { + color: #fbbf24; + border-color: rgba(251,191,36,0.3); +} +.friend-action-cancel:hover { + background: rgba(251,191,36,0.2); + border-color: rgba(251,191,36,0.6); +} + /* Friend button in profile */ .friend-btn { display: flex; diff --git a/src/components/Friends/FriendsList.jsx b/src/components/Friends/FriendsList.jsx index d69c316..26fa8e2 100644 --- a/src/components/Friends/FriendsList.jsx +++ b/src/components/Friends/FriendsList.jsx @@ -1,17 +1,20 @@ import React, { useEffect, useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { fetchFriends, fetchFriendRequests, acceptFriendRequest, rejectFriendRequest, + cancelFriendRequest, removeFriend, sendFriendRequest, + searchUsers, } from '../../api/client'; import { useAuth } from '../../auth/AuthContext'; -import { recoSearch } from '../../services/recoSearch'; import './Friends.css'; export default function FriendsList({ onSelectUser, onClose }) { + const { t } = useTranslation(); const { authenticated } = useAuth(); const [activeTab, setActiveTab] = useState('friends'); const [friends, setFriends] = useState([]); @@ -40,6 +43,8 @@ export default function FriendsList({ onSelectUser, onClose }) { fetchFriends(), fetchFriendRequests(), ]); + console.log('[Friends] requestsRes:', requestsRes); + console.log('[Friends] requests:', requestsRes?.requests); setFriends(friendsRes?.friends || []); setRequests(requestsRes?.requests || []); setLoading(false); @@ -49,7 +54,7 @@ export default function FriendsList({ onSelectUser, onClose }) { }, [authenticated]); // Search for users with debounce - const searchUsers = async (query) => { + const doSearchUsers = async (query) => { if (!query || query.length < 2) { setSuggestions([]); setShowSuggestions(false); @@ -58,13 +63,10 @@ export default function FriendsList({ onSelectUser, onClose }) { setSuggestionsLoading(true); try { - const results = await recoSearch(query, { limit: 8 }); - // Filter only profiles - const profiles = results.filter( - (r) => r.type === 'profile' || r.kind === 'profile' - ); - setSuggestions(profiles); - setShowSuggestions(profiles.length > 0); + const res = await searchUsers(query); + const users = res?.users || []; + setSuggestions(users); + setShowSuggestions(users.length > 0); } catch { setSuggestions([]); } @@ -80,7 +82,7 @@ export default function FriendsList({ onSelectUser, onClose }) { clearTimeout(searchTimeoutRef.current); } searchTimeoutRef.current = setTimeout(() => { - searchUsers(value); + doSearchUsers(value); }, 300); }; @@ -117,8 +119,17 @@ export default function FriendsList({ onSelectUser, onClose }) { } }; + const handleCancel = async (username) => { + console.log('[Friends] Canceling request to:', username); + const res = await cancelFriendRequest(username); + console.log('[Friends] Cancel response:', res); + if (res?.ok) { + setRequests((prev) => prev.filter((r) => r.username !== username)); + } + }; + const handleRemove = async (username) => { - if (!window.confirm(`Remove ${username} from friends?`)) return; + if (!window.confirm(t('friends.removeConfirm', { username }))) return; const res = await removeFriend(username); if (res?.ok) { setFriends((prev) => prev.filter((f) => f.username !== username)); @@ -138,11 +149,11 @@ export default function FriendsList({ onSelectUser, onClose }) { setAddLoading(false); if (res?.ok) { - setAddSuccess(`Friend request sent to ${uname}`); + setAddSuccess(t('friends.requestSent', { username: uname })); setAddUsername(''); setTimeout(() => setAddSuccess(''), 3000); } else { - setAddError(res?.error || res?.text || 'Could not send request'); + setAddError(res?.error || res?.text || t('friends.couldNotSend')); setTimeout(() => setAddError(''), 4000); } }; @@ -151,10 +162,10 @@ export default function FriendsList({ onSelectUser, onClose }) { return (
- Friends + {t('friends.title')} {onClose && }
-
Sign in to see your friends
+
{t('friends.signInToSee')}
); } @@ -162,7 +173,7 @@ export default function FriendsList({ onSelectUser, onClose }) { return (
- Friends + {t('friends.title')} {onClose && }
@@ -171,13 +182,13 @@ export default function FriendsList({ onSelectUser, onClose }) { className={`friends-tab ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')} > - Friends ({friends.length}) + {t('friends.friendsTab')} ({friends.length})
@@ -188,7 +199,7 @@ export default function FriendsList({ onSelectUser, onClose }) { ref={inputRef} type="text" className="friends-add-input" - placeholder="Add friend by username..." + placeholder={t('friends.addPlaceholder')} value={addUsername} onChange={handleInputChange} onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} @@ -200,7 +211,7 @@ export default function FriendsList({ onSelectUser, onClose }) { type="submit" className="friends-add-btn" disabled={addLoading || !addUsername.trim()} - title="Send friend request" + title={t('friends.sendRequest')} > {addLoading ? '...' : } @@ -213,27 +224,31 @@ export default function FriendsList({ onSelectUser, onClose }) {
handleSelectSuggestion(s.title || s.raw?.username || s.id)} + onMouseDown={() => handleSelectSuggestion(s.username)} >
- {(s.title || s.id)[0]?.toUpperCase() || '?'} + {s.avatar_url ? ( + {s.username} + ) : ( + s.username[0]?.toUpperCase() || '?' + )}
- {s.title || s.raw?.username || s.id} + {s.username}
))}
)} - {suggestionsLoading &&
Searching...
} + {suggestionsLoading &&
{t('friends.searching')}
} {addError &&
{addError}
} {addSuccess &&
{addSuccess}
}
{loading ? ( -
Loading...
+
{t('common.loading')}
) : activeTab === 'friends' ? ( friends.length === 0 ? ( -
No friends yet
+
{t('friends.noFriends')}
) : (
{friends.map((friend) => ( @@ -250,7 +265,7 @@ export default function FriendsList({ onSelectUser, onClose }) { @@ -259,38 +274,64 @@ export default function FriendsList({ onSelectUser, onClose }) {
) ) : requests.length === 0 ? ( -
No pending requests
+
{t('friends.noPending')}
) : (
- {requests.map((request) => ( -
+ {requests.map((request) => { + console.log('[Friends] Rendering request:', request.username, 'direction:', request.direction); + return ( +
onSelectUser && onSelectUser(request.username)} >
- {request.username[0].toUpperCase()} + {request.avatar_url ? ( + {request.username} + ) : ( + request.username[0]?.toUpperCase() || '?' + )} +
+
+ {request.username} + + {request.direction === 'sent' + ? t('friends.requestSentLabel', 'Request sent') + : t('friends.requestReceivedLabel', 'Wants to be your friend')} +
- {request.username}
- - + {request.direction === 'received' ? ( + <> + + + + ) : ( + + )}
- ))} + ); + })}
)}
diff --git a/src/components/Island/IslandUniverse.jsx b/src/components/Island/IslandUniverse.jsx index 06d7916..44bdd90 100644 --- a/src/components/Island/IslandUniverse.jsx +++ b/src/components/Island/IslandUniverse.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { fetchIslands } from '../../api/client'; @@ -9,6 +10,7 @@ import '../../styles/universe.css'; * Lightweight alternative to Three.js - uses existing MapLibre dependency */ export default function IslandUniverse({ onClose, onIslandClick }) { + const { t } = useTranslation(); const containerRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); @@ -187,8 +189,8 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
-

🌌 Island Universe

-

{islands.length} islands floating in virtual space

+

🌌 {t('universe.title')}

+

{t('universe.islandsCount', { count: islands.length })}

@{hoveredIsland.username}
-
{hoveredIsland.display_name || hoveredIsland.bio || 'Click to visit'}
+
{hoveredIsland.display_name || hoveredIsland.bio || t('universe.clickToVisit')}
- {hoveredIsland.content_count} posts • {hoveredIsland.terrain_type} + {hoveredIsland.content_count} {t('universe.posts')} • {hoveredIsland.terrain_type}
)}
-

🖱️ Hover over islands to see details • Click to visit

+

🖱️ {t('universe.help')}

diff --git a/src/components/Island/IslandViewer.jsx b/src/components/Island/IslandViewer.jsx index 4c267c2..e32dd1a 100644 --- a/src/components/Island/IslandViewer.jsx +++ b/src/components/Island/IslandViewer.jsx @@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import '../../styles/island.css'; -import { fetchPosts, updateProfileUsername, uploadProfileAvatar } from '../../api/client'; +import { fetchPosts, updateProfileUsername, uploadProfileAvatar, updateTimezone, getBrowserTimezone } from '../../api/client'; +import { setUserTimezone as saveTimezoneLocal, getUserTimezone as getStoredTimezone } from '../../utils/timezone'; import { useAuth } from '../../auth/AuthContext'; import { trackEvent } from "../../utils/analytics"; @@ -31,6 +32,9 @@ export default function IslandViewer({ island, onClose }) { const [usernameSaving, setUsernameSaving] = useState(false); const [usernameError, setUsernameError] = useState(""); const [usernameInfo, setUsernameInfo] = useState(""); + const [timezone, setTimezone] = useState(getStoredTimezone()); + const [timezoneSaving, setTimezoneSaving] = useState(false); + const [timezoneInfo, setTimezoneInfo] = useState(""); const islandUsername = localUsername || island?.username || ""; useEffect(() => { @@ -340,6 +344,61 @@ export default function IslandViewer({ island, onClose }) { {uploadInfo ?
{uploadInfo}
: null} {usernameError ?
{usernameError}
: null} {usernameInfo ?
{usernameInfo}
: null} + +
+ + +
+ {timezoneInfo ?
{timezoneInfo}
: null} ) : null} diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index 16a3ec3..78f96f3 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useAuth } from "../../auth/AuthContext"; import { registerUser, updateProfileUsername, fetchFriendRequests } from "../../api/client"; import AuthToast from "../Auth/AuthToast"; @@ -6,7 +7,8 @@ import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall'; import { trackEvent } from "../../utils/analytics"; import FriendsList from "../Friends/FriendsList"; -const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; +const SUPPORTED_LANGS = ['en', 'fr', 'es']; +const THEMES = ['dark', 'blue', 'light']; const LAST_SIGNUP_KEY = "sociowire:lastSignup"; function initialLetter(name = "") { @@ -56,6 +58,7 @@ function parseMergedParams() { } export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) { + const { t, i18n } = useTranslation(); const { loading, authenticated, @@ -75,6 +78,58 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, clearInfo, } = useAuth(); + const [showLangMenu, setShowLangMenu] = useState(false); + const [langMenuPos, setLangMenuPos] = useState({ top: 0, right: 0 }); + const langBtnRef = useRef(null); + + const [showThemeMenu, setShowThemeMenu] = useState(false); + const [themeMenuPos, setThemeMenuPos] = useState({ top: 0, right: 0 }); + const themeBtnRef = useRef(null); + + const changeLanguage = (lang) => { + i18n.changeLanguage(lang); + setShowLangMenu(false); + }; + + const toggleLangMenu = () => { + if (!showLangMenu && langBtnRef.current) { + const rect = langBtnRef.current.getBoundingClientRect(); + setLangMenuPos({ + top: rect.bottom + 6, + right: window.innerWidth - rect.right, + }); + } + setShowLangMenu(!showLangMenu); + setShowThemeMenu(false); + }; + + const toggleThemeMenu = () => { + if (!showThemeMenu && themeBtnRef.current) { + const rect = themeBtnRef.current.getBoundingClientRect(); + setThemeMenuPos({ + top: rect.bottom + 6, + right: window.innerWidth - rect.right, + }); + } + setShowThemeMenu(!showThemeMenu); + setShowLangMenu(false); + }; + + // Close lang/theme menu when clicking outside + useEffect(() => { + if (!showLangMenu && !showThemeMenu) return; + const handleClick = (e) => { + if (showLangMenu && langBtnRef.current && !langBtnRef.current.contains(e.target)) { + setShowLangMenu(false); + } + if (showThemeMenu && themeBtnRef.current && !themeBtnRef.current.contains(e.target)) { + setShowThemeMenu(false); + } + }; + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + }, [showLangMenu, showThemeMenu]); + const [showAuth, setShowAuth] = useState(false); const [mode, setMode] = useState("login"); const [showUsernameModal, setShowUsernameModal] = useState(false); @@ -140,7 +195,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, const next = (desiredUsername || "").trim(); const re = /^[A-Za-z0-9_.]{3,24}$/; if (!re.test(next) || next.includes("@")) { - setUsernameError("Use 3-24 letters, numbers, _ or ."); + setUsernameError(t('auth.usernameHint')); return; } setUsernameSaving(true); @@ -150,13 +205,13 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, setUsernameSaving(false); if (!res.ok) { if (res.status === 409) { - setUsernameError("Username already taken."); + setUsernameError(t('auth.usernameTaken')); } else { - setUsernameError(res.text || "Could not update username."); + setUsernameError(res.text || t('auth.usernameUpdateError')); } return; } - setUsernameInfo("Username saved."); + setUsernameInfo(t('auth.usernameSaved')); setShowUsernameModal(false); await restore(); }; @@ -172,8 +227,15 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, return; } const loadRequests = async () => { - const res = await fetchFriendRequests(); - setPendingRequestsCount(res?.requests?.length || 0); + try { + const res = await fetchFriendRequests(); + // Only count received requests (not sent ones) + const received = (res?.requests || []).filter(r => r?.direction === 'received'); + setPendingRequestsCount(received.length); + } catch (err) { + console.error('[TopBar] Error fetching friend requests:', err); + setPendingRequestsCount(0); + } }; loadRequests(); // Refresh every 30 seconds @@ -243,7 +305,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, const detail = e?.detail || {}; const msg = (detail.message || "").toString().trim() || - "Please sign in or create a free account to continue."; + t('auth.pleaseSignIn'); setAuthNotice(msg); setMode(detail.mode === "signup" ? "signup" : "login"); setShowAuth(true); @@ -307,7 +369,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, if (truthyParam(verified)) { const who = (email || uname || last?.email || last?.username || "").trim(); - setSignupInfo("✅ Email confirmed" + (who ? " (" + who + ")" : "") + ". You can log in."); + setSignupInfo("✅ " + t('auth.emailConfirmedMsg') + (who ? " (" + who + ")" : "")); setMode("login"); setShowAuth(true); @@ -315,7 +377,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, if (prefill) setLoginUser(prefill); } else if (truthyParam(signup)) { const who = (email || uname || last?.email || last?.username || "").trim(); - setSignupInfo("✅ Account created" + (who ? " (" + who + ")" : "") + ". Check your emails (spam too), then log in."); + setSignupInfo("✅ " + t('auth.accountCreatedMsg') + (who ? " (" + who + ")" : "")); setMode("login"); setShowAuth(true); @@ -357,7 +419,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, setSignupInfo(""); if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) { - setSignupError("First name, last name, username, email and password are required."); + setSignupError(t('auth.fieldsRequired')); return; } @@ -380,14 +442,14 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em })); } catch {} - setSignupInfo("✅ Account created (" + em + "). Check your emails (spam too), then log in."); + setSignupInfo("✅ " + t('auth.accountCreatedMsg') + " (" + em + ")"); trackEvent("signup_success"); setMode("login"); setLoginUser(u || em); setLoginPass(""); } catch (err) { console.error(err); - setSignupError(err.message || "Registration error."); + setSignupError(err.message || t('auth.registrationError')); } finally { setSignupLoading(false); } @@ -395,15 +457,15 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, const toast = useMemo(() => { if (lastError) { - const t = needsEmailVerification ? "warn" : "error"; - const title = needsEmailVerification ? "Email verification required" : "Login failed"; - return { type: t, title, message: lastError }; + const toastType = needsEmailVerification ? "warn" : "error"; + const title = needsEmailVerification ? t('auth.emailVerificationRequired') : t('auth.loginFailed'); + return { type: toastType, title, message: lastError }; } if (lastInfo) { return { type: "success", title: "", message: lastInfo }; } return null; - }, [lastError, lastInfo, needsEmailVerification]); + }, [lastError, lastInfo, needsEmailVerification, t]); return ( <> @@ -428,22 +490,22 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
SocioWire
SOCIOWIRE.com
-
Wired to life
+
{t('topbar.wiredToLife')}
{/* Theme dots */}
- {["dark", "blue", "light"].map((t) => ( + {["dark", "blue", "light"].map((themeKey) => ( ))}
@@ -464,18 +526,18 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, {avatarUrl ? {username} : {initialLetter(username)}}
-
Online
+
{t('common.online')}
@{displayUsername}
) : ( -
+
-
{loading ? "Loading…" : "Guest"}
-
anon
+
{loading ? t('common.loading') : t('common.guest')}
+
{t('common.anon')}
)} @@ -490,19 +552,83 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, className="btn-action btn-action-install" type="button" onClick={() => promptInstall()} - title="Install App" + title={t('topbar.installApp')} > )} + {/* Theme selector (mobile dropdown) */} +
+ + {showThemeMenu && ( +
+ {THEMES.map((themeKey) => ( + + ))} +
+ )} +
+ + {/* Language selector */} +
+ + {showLangMenu && ( +
+ {SUPPORTED_LANGS.map((lang) => ( + + ))} +
+ )} +
+ {/* Friends button - only when authenticated */} {authenticated && (
@@ -542,14 +668,14 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")} onClick={() => setMode("login")} > - Login + {t('auth.login')} {needsEmailVerification ? (
- ⚠️ Your account requires email verification. Confirm your email, then try logging in again. + ⚠️ {t('auth.verifyEmail')}
) : null} {ssoEnabled ? ( <> -
or continue with
+
{t('auth.orContinueWith')}
@@ -638,31 +764,31 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
{signupError ?
{signupError}
: null} )} @@ -674,16 +800,16 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
{}}>
e.stopPropagation()}>
-
Choose username
+
{t('auth.chooseUsername')}
- Pick a public username to complete your profile. + {t('auth.pickUsername')}
{usernameError ?
{usernameError}
: null} {usernameInfo ?
{usernameInfo}
: null}
diff --git a/src/components/Map/MapControls.jsx b/src/components/Map/MapControls.jsx index 4b7ba31..3836a5b 100644 --- a/src/components/Map/MapControls.jsx +++ b/src/components/Map/MapControls.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import "../../styles/mapControls.css"; function getCurrentTheme() { @@ -16,6 +17,7 @@ function getCurrentTheme() { } export default function MapControls({ onMySpot, onPostWire }) { + const { t } = useTranslation(); const theme = getCurrentTheme(); return ( @@ -25,7 +27,7 @@ export default function MapControls({ onMySpot, onPostWire }) { className="map-controls__button map-controls__button--myspot" onClick={onMySpot} > - My spot + {t('controls.mySpot')}
); diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index c5ba5cd..6c26972 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -25,6 +25,7 @@ import { updatePostVisibility, } from "../../api/client"; import { trackEvent } from "../../utils/analytics"; +import { formatRelativeTime } from "../../utils/timezone"; const avatarCache = new Map(); const avatarInflight = new Map(); @@ -62,7 +63,12 @@ function refreshMarkerFromPost(map, postId, fresh) { const list = ref?.current || []; for (const item of list) { if (!item || item.id !== postId) continue; + // Preserve fetched truth_score if fresh data doesn't have it + const oldTruth = item.post?.truth_score; const nextPost = { ...(item.post || {}), ...(fresh || {}) }; + if (Number.isFinite(oldTruth) && !Number.isFinite(Number(fresh?.truth_score))) { + nextPost.truth_score = oldTruth; + } item.post = nextPost; if (!item.el) continue; item.el.__post = nextPost; @@ -319,7 +325,9 @@ function renderComments(container, items) { .map((c) => { const who = (c?.username || "anon").toString().trim(); const body = (c?.body || "").toString().trim(); - const ts = (c?.created_at || "").toString().trim(); + const rawTs = (c?.created_at || "").toString().trim(); + // Format timestamp in user's timezone + const ts = rawTs ? formatRelativeTime(rawTs) : ""; return `
@@ -714,25 +722,6 @@ function collectGalleryImages(post) { return result; } -function formatRelativeTime(createdAt) { - try { - if (!createdAt) return "now"; - const d = createdAt instanceof Date ? createdAt : new Date(String(createdAt)); - if (Number.isNaN(d.getTime())) return "now"; - - const s = Math.floor((Date.now() - d.getTime()) / 1000); - if (s < 60) return "now"; - const m = Math.floor(s / 60); - if (m < 60) return `${m}m`; - const h = Math.floor(m / 60); - if (h < 48) return `${h}h`; - const days = Math.floor(h / 24); - return `${days}d`; - } catch { - return "now"; - } -} - function getPostTime(post) { const published = post?.published_at || @@ -756,7 +745,124 @@ function getPostTime(post) { ); } +// Known trusted sources get higher base scores +const SOURCE_REPUTATION = { + // Major news agencies + 'reuters': 85, 'ap': 85, 'afp': 82, + // Government/official + 'gov': 80, 'gouv': 80, 'canada.ca': 82, 'quebec.ca': 80, + // Major outlets + 'bbc': 78, 'cbc': 78, 'radio-canada': 78, 'nytimes': 76, 'theguardian': 75, + 'lapresse': 74, 'ledevoir': 74, 'journaldemontreal': 70, + // Tech + 'techcrunch': 72, 'theverge': 70, 'arstechnica': 74, + // Social/user content (lower trust) + 'twitter': 45, 'x.com': 45, 'facebook': 48, 'reddit': 50, 'tiktok': 42, +}; + +// Clickbait/sensational words reduce score +const CLICKBAIT_WORDS = [ + 'shocking', 'unbelievable', 'you won\'t believe', 'mind-blowing', 'insane', + 'choquant', 'incroyable', 'hallucinant', 'fou', 'dingue', + 'breaking', 'urgent', 'exclusive', 'leaked', 'secret', + '!!!', '???', 'exposed', 'révélé', 'scandale' +]; + +// Credibility indicators increase score +const CREDIBILITY_WORDS = [ + 'study', 'research', 'according to', 'report', 'official', + 'étude', 'recherche', 'selon', 'rapport', 'officiel', + 'confirmed', 'confirmé', 'announced', 'annoncé', 'statement' +]; + +function calculateInitialTruthScore(post) { + let score = 55; // Base score + let factors = 0; + + // 1. Source reputation (weight: 40%) + const source = (post?.source || post?.Source || '').toLowerCase(); + const domain = (post?.domain || post?.url || '').toLowerCase(); + for (const [key, rep] of Object.entries(SOURCE_REPUTATION)) { + if (source.includes(key) || domain.includes(key)) { + score = rep; + factors++; + break; + } + } + + // 2. Author info (weight: 15%) + const author = post?.author || post?.Author || ''; + if (author) { + score += 3; // Has attributed author + factors++; + // Verified authors get bonus + if (post?.author_verified || post?.verified) { + score += 5; + } + } + + // 3. Content analysis (weight: 25%) + const title = (post?.title || post?.headline || '').toLowerCase(); + const content = (post?.content || post?.description || post?.summary || '').toLowerCase(); + const text = title + ' ' + content; + + // Check for clickbait + let clickbaitCount = 0; + for (const word of CLICKBAIT_WORDS) { + if (text.includes(word.toLowerCase())) { + clickbaitCount++; + } + } + if (clickbaitCount > 0) { + score -= Math.min(clickbaitCount * 4, 15); // Max -15 for clickbait + factors++; + } + + // Check for credibility indicators + let credibilityCount = 0; + for (const word of CREDIBILITY_WORDS) { + if (text.includes(word.toLowerCase())) { + credibilityCount++; + } + } + if (credibilityCount > 0) { + score += Math.min(credibilityCount * 3, 10); // Max +10 for credibility + factors++; + } + + // 4. Content quality indicators (weight: 20%) + // Longer content tends to be more detailed/trustworthy + const contentLength = (content || '').length; + if (contentLength > 500) score += 3; + else if (contentLength > 200) score += 1; + else if (contentLength < 50) score -= 3; + + // Has image/media (more complete reporting) + if (post?.image || post?.media_url || post?.image_url) { + score += 2; + } + + // Has URL to original source + if (post?.url || post?.link) { + score += 2; + } + + // 5. Engagement signals (if available) + const likes = Number(post?.like_count || post?.likes || 0); + const shares = Number(post?.share_count || post?.shares || 0); + const views = Number(post?.view_count || post?.views || 0); + + // High engagement can be positive or negative - slight positive bias for established content + if (views > 1000 && likes > 50) { + score += 2; + } + + // Clamp to valid range + return clamp(Math.round(score), 25, 85); // Never go below 25 or above 85 without AI verification +} + function truthScore(post) { + // First check if we have an AI-verified score const raw = Number( post?.truth_score ?? post?.truthScore ?? @@ -768,7 +874,8 @@ function truthScore(post) { if (Number.isFinite(raw)) { return clamp(Math.round(raw), 0, 100); } - return 65; + // Calculate initial score from available data + return calculateInitialTruthScore(post); } function truthColor(score) { diff --git a/src/components/Posts/PostCard.jsx b/src/components/Posts/PostCard.jsx index a4334b2..5b5b3b3 100644 --- a/src/components/Posts/PostCard.jsx +++ b/src/components/Posts/PostCard.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { loadAuth } from "../../auth/authStorage"; import { fetchPostLikeState, @@ -32,6 +33,8 @@ function requestAuth(message) { trackEvent("auth_required", { context: "wall", mode: "login" }); } +// Time formatting is now handled in the component with translations + function formatRelativeTime(createdAt) { try { if (!createdAt) return "now"; @@ -132,6 +135,7 @@ async function getAvatarForUser(name) { } export default function PostCard({ post, selected, onSelect, onPostDeleted, onPostUpdated }) { + const { t } = useTranslation(); const [liked, setLiked] = useState(false); const [authed, setAuthed] = useState(isAuthed()); const [activeImageIndex, setActiveImageIndex] = useState(0); @@ -411,7 +415,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo onClick={async (e) => { e.stopPropagation(); if (!authed) { - requestAuth("Please sign in or create a free account to like this post."); + requestAuth(t('post.signInToLike')); return; } trackEvent("post_like", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat }); @@ -428,7 +432,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo } }} > - Like + {t('post.like')} {isAuthor ? ( @@ -483,7 +487,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo setShowEditDelete(false); }} > - Visibility + {t('post.visibility')}
) : null} @@ -592,14 +596,14 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo className="sw-wall-btn" onClick={() => setIsEditing(true)} > - Edit + {t('common.edit')} ) : null} @@ -609,13 +613,13 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo setEditTitle(e.target.value)} />