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')}
- Open Islands
+ {t('islands.openIslands')}
- {/* Bouton Contacts flottant en bas à droite */}
-
setShowContactsList(!showContactsList)}>
- 👥
- Contacts
-
-
- {/* 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}
-
setActiveChats(prev => prev.filter(n => n !== 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 (
+
0 ? " has-unread" : "")}
+ onClick={() => toggleMinimize(chat.username)}
+ >
+
+ {chat.avatar ? (
+
+ ) : (
+
{(chat.username || "?")[0].toUpperCase()}
+ )}
+
+ {chat.username}
+ {unread > 0 && {unread} }
+ { e.stopPropagation(); closeChat(chat.username); }}
+ >
+
+
+
+ );
+ })}
+
+ {/* Contacts Button */}
+
+
+
+
+ {t("common.contacts")}
+ {totalUnread > 0 && {totalUnread} }
+ {authenticated && (
+
+ )}
+
+
+
+ {/* Contacts Dropdown */}
+ {showContacts && (
+
+
+ {t("common.contacts")}
+ setShowContacts(false)}
+ >
+
+
+
+
+ {!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 || "?")[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 || "?")[0].toUpperCase()}
+ )}
+
+
{recipientName}
+
+
{
+ e.stopPropagation();
+ onClose?.();
+ }}
+ >
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {recipientAvatar ? (
+
+ ) : (
+
{(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 }) {
Searching...
}
+ {suggestionsLoading &&
- ⚠️ Your account requires email verification. Confirm your email, then try logging in again.
+ ⚠️ {t('auth.verifyEmail')}
) : null}
{ssoEnabled ? (
<>
-