This commit is contained in:
2026-03-15 12:09:18 -04:00
parent c8b43dea99
commit b6a6989319
69 changed files with 10037 additions and 90 deletions

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icons/jama.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#1a73e8" />
<meta name="description" content="jama - just another messaging app" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>jama</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "jama-frontend",
"version": "0.9.23",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"socket.io-client": "^4.6.1",
"emoji-mart": "^5.5.2",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"papaparse": "^5.4.1",
"date-fns": "^3.3.1",
"marked": "^12.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.1.4"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,38 @@
{
"name": "jama",
"short_name": "jama",
"description": "Modern team messaging application",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#1a73e8",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"min_width": "320px"
}

109
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,109 @@
const CACHE_NAME = 'jama-v1';
const STATIC_ASSETS = ['/'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const url = event.request.url;
if (url.includes('/api/') || url.includes('/socket.io/') || url.includes('/manifest.json')) {
return;
}
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});
// Track badge count in SW scope
let badgeCount = 0;
self.addEventListener('push', (event) => {
if (!event.data) return;
let data = {};
try { data = event.data.json(); } catch (e) { return; }
badgeCount++;
// Update app badge
if (self.navigator && self.navigator.setAppBadge) {
self.navigator.setAppBadge(badgeCount).catch(() => {});
}
// Check if app is currently visible — if so, skip the notification
const showNotification = clients.matchAll({
type: 'window',
includeUncontrolled: true,
}).then((clientList) => {
const appVisible = clientList.some(
(c) => c.visibilityState === 'visible'
);
// Still show if app is open but hidden (minimized), skip only if truly visible
if (appVisible) return;
return self.registration.showNotification(data.title || 'New Message', {
body: data.body || '',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192-maskable.png',
data: { url: data.url || '/' },
// Use unique tag per group so notifications group by conversation
tag: data.groupId ? `jama-group-${data.groupId}` : 'jama-message',
renotify: true,
});
});
event.waitUntil(showNotification);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
badgeCount = 0;
if (self.navigator && self.navigator.clearAppBadge) {
self.navigator.clearAppBadge().catch(() => {});
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
const url = event.notification.data?.url || '/';
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.focus();
return;
}
}
return clients.openWindow(url);
})
);
});
// Clear badge when app signals it
self.addEventListener('message', (event) => {
if (event.data?.type === 'CLEAR_BADGE') {
badgeCount = 0;
if (self.navigator && self.navigator.clearAppBadge) {
self.navigator.clearAppBadge().catch(() => {});
}
}
if (event.data?.type === 'SET_BADGE') {
badgeCount = event.data.count || 0;
if (self.navigator && self.navigator.setAppBadge) {
if (badgeCount > 0) {
self.navigator.setAppBadge(badgeCount).catch(() => {});
} else {
self.navigator.clearAppBadge().catch(() => {});
}
}
}
});

54
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,54 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext.jsx';
import { SocketProvider } from './contexts/SocketContext.jsx';
import { ToastProvider } from './contexts/ToastContext.jsx';
import Login from './pages/Login.jsx';
import Chat from './pages/Chat.jsx';
import ChangePassword from './pages/ChangePassword.jsx';
function ProtectedRoute({ children }) {
const { user, loading, mustChangePassword } = useAuth();
if (loading) return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" style={{ width: 36, height: 36 }} />
</div>
);
if (!user) return <Navigate to="/login" replace />;
if (mustChangePassword) return <Navigate to="/change-password" replace />;
return children;
}
function AuthRoute({ children }) {
const { user, loading, mustChangePassword } = useAuth();
// Always show login in light mode regardless of user's saved theme preference
document.documentElement.setAttribute('data-theme', 'light');
if (loading) return null;
if (user && !mustChangePassword) return <Navigate to="/" replace />;
return children;
}
function RestoreTheme() {
// Called when entering a protected route — restore the user's saved theme
const saved = localStorage.getItem('jama-theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
return null;
}
export default function App() {
return (
<BrowserRouter>
<ToastProvider>
<AuthProvider>
<SocketProvider>
<Routes>
<Route path="/login" element={<AuthRoute><Login /></AuthRoute>} />
<Route path="/change-password" element={<ChangePassword />} />
<Route path="/" element={<ProtectedRoute><RestoreTheme /><Chat /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SocketProvider>
</AuthProvider>
</ToastProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react';
import { api } from '../utils/api.js';
const CLAUDE_URL = 'https://claude.ai';
// Render "Built With" value — separator trails its token so it never starts a new line
function BuiltWithValue({ value }) {
if (!value) return null;
const parts = value.split('·').map(s => s.trim());
return (
<span style={{ display: 'inline' }}>
{parts.map((part, i) => (
<span key={part} style={{ whiteSpace: 'nowrap' }}>
{part === 'Claude.ai'
? <a href={CLAUDE_URL} target="_blank" rel="noreferrer" className="about-link">{part}</a>
: part}
{i < parts.length - 1 && <span style={{ margin: '0 4px', color: 'var(--text-tertiary)' }}>·</span>}
</span>
))}
</span>
);
}
export default function AboutModal({ onClose }) {
const [about, setAbout] = useState(null);
useEffect(() => {
fetch('/api/about')
.then(r => r.json())
.then(({ about }) => setAbout(about))
.catch(() => {});
}, []);
// Always use the original app identity — not the user-customised settings name/logo
const appName = about?.default_app_name || 'jama';
const logoSrc = about?.default_logo || '/icons/jama.png';
const version = about?.version || '';
const a = about || {};
const rows = [
{ label: 'Version', value: version },
{ label: 'Built With', value: a.built_with, builtWith: true },
{ label: 'Developer', value: a.developer },
{ label: 'License', value: a.license, link: a.license_url },
].filter(r => r.value);
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal about-modal">
<button className="btn-icon about-close" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<div className="about-hero">
<img src={logoSrc} alt={appName} className="about-logo" />
<h1 className="about-appname">{appName}</h1>
<p className="about-tagline">just another messaging app</p>
</div>
{about ? (
<>
<div className="about-table">
{rows.map(({ label, value, builtWith, link }) => (
<div className="about-row" key={label}>
<span className="about-label">{label}</span>
<span className="about-value">
{builtWith
? <BuiltWithValue value={value} />
: link
? <a href={link} target="_blank" rel="noreferrer" className="about-link">{value}</a>
: value}
</span>
</div>
))}
</div>
{a.description && <p className="about-footer">{a.description}</p>}
</>
) : (
<div className="flex justify-center" style={{ padding: 24 }}><div className="spinner" /></div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
export default function Avatar({ user, size = 'md', className = '' }) {
if (!user) return null;
const initials = (() => {
const name = user.display_name || user.name || '';
const parts = name.trim().split(' ').filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return '??';
})();
const colors = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
const colorIdx = (user.name || '').charCodeAt(0) % colors.length;
const bg = colors[colorIdx];
return (
<div className={`avatar avatar-${size} ${className}`} style={{ background: user.avatar ? undefined : bg }}>
{user.avatar
? <img src={user.avatar} alt={initials} />
: initials
}
</div>
);
}

View File

@@ -0,0 +1,559 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
const DEFAULT_TITLE_COLOR = '#1a73e8'; // light mode default
const DEFAULT_TITLE_DARK_COLOR = '#60a5fa'; // dark mode default (lighter blue readable on dark bg)
const DEFAULT_PUBLIC_COLOR = '#1a73e8';
const DEFAULT_DM_COLOR = '#a142f4';
const COLOUR_SUGGESTIONS = [
'#1a73e8', '#a142f4', '#e53935', '#fa7b17', '#fdd835', '#34a853',
];
// ── Title Colour Row — one row per mode ──────────────────────────────────────
function TitleColourRow({ bgColor, bgLabel, textColor, onChange }) {
const [mode, setMode] = useState('idle'); // 'idle' | 'custom'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{/* Preview box */}
<div style={{
background: bgColor, borderRadius: 8, padding: '0 14px',
height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1px solid var(--border)', minWidth: 110, flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
}}>
<span style={{ color: textColor, fontWeight: 700, fontSize: 16 }}>
Title
</span>
</div>
{mode === 'idle' && (
<>
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', fontFamily: 'monospace', minWidth: 64 }}>{textColor}</span>
<button className="btn btn-secondary btn-sm" onClick={() => setMode('custom')}>Custom</button>
</>
)}
{mode === 'custom' && (
<div style={{ flex: 1 }}>
<CustomPicker
initial={textColor}
onSet={(hex) => { onChange(hex); setMode('idle'); }}
onBack={() => setMode('idle')}
/>
</div>
)}
</div>
);
}
// ── Colour math helpers ──────────────────────────────────────────────────────
function hexToHsv(hex) {
const r = parseInt(hex.slice(1,3),16)/255;
const g = parseInt(hex.slice(3,5),16)/255;
const b = parseInt(hex.slice(5,7),16)/255;
const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max - min;
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
}
return { h: h * 360, s: max === 0 ? 0 : d / max, v: max };
}
function hsvToHex(h, s, v) {
h = h / 360;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r=v; g=t; b=p; break; case 1: r=q; g=v; b=p; break;
case 2: r=p; g=v; b=t; break; case 3: r=p; g=q; b=v; break;
case 4: r=t; g=p; b=v; break; default: r=v; g=p; b=q;
}
return '#' + [r,g,b].map(x => Math.round(x*255).toString(16).padStart(2,'0')).join('');
}
function isValidHex(h) { return /^#[0-9a-fA-F]{6}$/.test(h); }
// ── SV (saturation/value) square ─────────────────────────────────────────────
function SvSquare({ hue, s, v, onChange }) {
const canvasRef = useRef(null);
const dragging = useRef(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// White → hue gradient (left→right)
const hGrad = ctx.createLinearGradient(0, 0, W, 0);
hGrad.addColorStop(0, '#fff');
hGrad.addColorStop(1, `hsl(${hue},100%,50%)`);
ctx.fillStyle = hGrad; ctx.fillRect(0, 0, W, H);
// Transparent → black gradient (top→bottom)
const vGrad = ctx.createLinearGradient(0, 0, 0, H);
vGrad.addColorStop(0, 'transparent');
vGrad.addColorStop(1, '#000');
ctx.fillStyle = vGrad; ctx.fillRect(0, 0, W, H);
}, [hue]);
const getPos = (e, canvas) => {
const r = canvas.getBoundingClientRect();
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
return {
s: Math.max(0, Math.min(1, cx / r.width)),
v: Math.max(0, Math.min(1, 1 - cy / r.height)),
};
};
const handle = (e) => {
e.preventDefault();
const p = getPos(e, canvasRef.current);
onChange(p.s, p.v);
};
return (
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none' }}>
<canvas
ref={canvasRef} width={260} height={160}
style={{ display: 'block', width: '100%', height: 160, borderRadius: 8, cursor: 'crosshair', border: '1px solid var(--border)' }}
onMouseDown={e => { dragging.current = true; handle(e); }}
onMouseMove={e => { if (dragging.current) handle(e); }}
onMouseUp={() => { dragging.current = false; }}
onMouseLeave={() => { dragging.current = false; }}
onTouchStart={handle} onTouchMove={handle}
/>
{/* Cursor circle */}
<div style={{
position: 'absolute',
left: `calc(${s * 100}% - 7px)`,
top: `calc(${(1 - v) * 100}% - 7px)`,
width: 14, height: 14, borderRadius: '50%',
border: '2px solid white',
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.4)',
pointerEvents: 'none',
background: 'transparent',
}} />
</div>
);
}
// ── Hue bar ───────────────────────────────────────────────────────────────────
function HueBar({ hue, onChange }) {
const barRef = useRef(null);
const dragging = useRef(false);
const handle = (e) => {
e.preventDefault();
const r = barRef.current.getBoundingClientRect();
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
onChange(Math.max(0, Math.min(360, (cx / r.width) * 360)));
};
return (
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none', marginTop: 10 }}>
<div
ref={barRef}
style={{
height: 20, borderRadius: 10,
background: 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)',
border: '1px solid var(--border)', cursor: 'pointer',
}}
onMouseDown={e => { dragging.current = true; handle(e); }}
onMouseMove={e => { if (dragging.current) handle(e); }}
onMouseUp={() => { dragging.current = false; }}
onMouseLeave={() => { dragging.current = false; }}
onTouchStart={handle} onTouchMove={handle}
/>
<div style={{
position: 'absolute',
left: `calc(${(hue / 360) * 100}% - 9px)`,
top: -2, width: 18, height: 24, borderRadius: 4,
background: `hsl(${hue},100%,50%)`,
border: '2px solid white',
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.3)',
pointerEvents: 'none',
}} />
</div>
);
}
// ── Custom HSV picker ─────────────────────────────────────────────────────────
function CustomPicker({ initial, onSet, onBack }) {
const { h: ih, s: is, v: iv } = hexToHsv(initial);
const [hue, setHue] = useState(ih);
const [sat, setSat] = useState(is);
const [val, setVal] = useState(iv);
const [hexInput, setHexInput] = useState(initial);
const [hexError, setHexError] = useState(false);
const current = hsvToHex(hue, sat, val);
// Sync hex input when sliders change
useEffect(() => { setHexInput(current); setHexError(false); }, [current]);
const handleHexInput = (e) => {
const v = e.target.value;
setHexInput(v);
if (isValidHex(v)) {
const { h, s, v: bv } = hexToHsv(v);
setHue(h); setSat(s); setVal(bv);
setHexError(false);
} else {
setHexError(true);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<SvSquare hue={hue} s={sat} v={val} onChange={(s, v) => { setSat(s); setVal(v); }} />
<HueBar hue={hue} onChange={setHue} />
{/* Preview + hex input */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
<div style={{
width: 40, height: 40, borderRadius: 8, background: current,
border: '2px solid var(--border)', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
}} />
<input
value={hexInput}
onChange={handleHexInput}
maxLength={7}
style={{
fontFamily: 'monospace', fontSize: 14,
padding: '6px 10px', borderRadius: 8,
border: `1px solid ${hexError ? '#e53935' : 'var(--border)'}`,
width: 110, background: 'var(--surface)',
color: 'var(--text-primary)',
}}
placeholder="#000000"
/>
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Chosen colour</span>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: 2 }}>
<button className="btn btn-primary btn-sm" onClick={() => onSet(current)} disabled={hexError}>
Set
</button>
<button className="btn btn-secondary btn-sm" onClick={onBack}>
Back
</button>
</div>
</div>
);
}
// ── ColourPicker card ─────────────────────────────────────────────────────────
function ColourPicker({ label, value, onChange, preview }) {
const [mode, setMode] = useState('suggestions'); // 'suggestions' | 'custom'
return (
<div>
<div className="settings-section-label">{label}</div>
{/* Current colour preview */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
{preview
? preview(value)
: <div style={{ width: 36, height: 36, borderRadius: 8, background: value, border: '2px solid var(--border)', flexShrink: 0 }} />
}
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{value}</span>
</div>
{mode === 'suggestions' && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{COLOUR_SUGGESTIONS.map(hex => (
<button
key={hex}
onClick={() => onChange(hex)}
style={{
width: 32, height: 32, borderRadius: 8,
background: hex, border: hex === value ? '3px solid var(--text-primary)' : '2px solid var(--border)',
cursor: 'pointer', flexShrink: 0,
boxShadow: hex === value ? '0 0 0 2px var(--surface), 0 0 0 4px var(--text-primary)' : 'none',
transition: 'box-shadow 0.15s',
}}
title={hex}
/>
))}
</div>
<button className="btn btn-secondary btn-sm" onClick={() => setMode('custom')}>
Custom
</button>
</>
)}
{mode === 'custom' && (
<CustomPicker
initial={value}
onSet={(hex) => { onChange(hex); setMode('suggestions'); }}
onBack={() => setMode('suggestions')}
/>
)}
</div>
);
}
export default function BrandingModal({ onClose }) {
const toast = useToast();
const [tab, setTab] = useState('general'); // 'general' | 'colours'
const [settings, setSettings] = useState({});
const [appName, setAppName] = useState('');
const [loading, setLoading] = useState(false);
const [resetting, setResetting] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [colourTitle, setColourTitle] = useState(DEFAULT_TITLE_COLOR);
const [colourTitleDark, setColourTitleDark] = useState(DEFAULT_TITLE_DARK_COLOR);
const [colourPublic, setColourPublic] = useState(DEFAULT_PUBLIC_COLOR);
const [colourDm, setColourDm] = useState(DEFAULT_DM_COLOR);
const [savingColours, setSavingColours] = useState(false);
useEffect(() => {
api.getSettings().then(({ settings }) => {
setSettings(settings);
setAppName(settings.app_name || 'jama');
setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR);
setColourTitleDark(settings.color_title_dark || DEFAULT_TITLE_DARK_COLOR);
setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
setColourDm(settings.color_avatar_dm || DEFAULT_DM_COLOR);
}).catch(() => {});
}, []);
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
const handleSaveName = async () => {
if (!appName.trim()) return;
setLoading(true);
try {
await api.updateAppName(appName.trim());
setSettings(prev => ({ ...prev, app_name: appName.trim() }));
toast('App name updated', 'success');
notifySidebarRefresh();
} catch (e) {
toast(e.message, 'error');
} finally {
setLoading(false);
}
};
const handleLogoUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 1024 * 1024) return toast('Logo must be less than 1MB', 'error');
try {
const { logoUrl } = await api.uploadLogo(file);
setSettings(prev => ({ ...prev, logo_url: logoUrl }));
toast('Logo updated', 'success');
notifySidebarRefresh();
} catch (e) {
toast(e.message, 'error');
}
};
const handleSaveColours = async () => {
setSavingColours(true);
try {
await api.updateColors({
colorTitle: colourTitle,
colorTitleDark: colourTitleDark,
colorAvatarPublic: colourPublic,
colorAvatarDm: colourDm,
});
setSettings(prev => ({
...prev,
color_title: colourTitle,
color_title_dark: colourTitleDark,
color_avatar_public: colourPublic,
color_avatar_dm: colourDm,
}));
toast('Colours updated', 'success');
notifySidebarRefresh();
} catch (e) {
toast(e.message, 'error');
} finally {
setSavingColours(false);
}
};
const handleReset = async () => {
setResetting(true);
try {
await api.resetSettings();
const { settings: fresh } = await api.getSettings();
setSettings(fresh);
setAppName(fresh.app_name || 'jama');
setColourTitle(DEFAULT_TITLE_COLOR);
setColourTitleDark(DEFAULT_TITLE_DARK_COLOR);
setColourPublic(DEFAULT_PUBLIC_COLOR);
setColourDm(DEFAULT_DM_COLOR);
toast('Settings reset to defaults', 'success');
notifySidebarRefresh();
setShowResetConfirm(false);
} catch (e) {
toast(e.message, 'error');
} finally {
setResetting(false);
}
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 460 }}>
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Branding</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* Tabs */}
<div className="flex gap-2" style={{ marginBottom: 24 }}>
<button className={`btn btn-sm ${tab === 'general' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('general')}>General</button>
<button className={`btn btn-sm ${tab === 'colours' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('colours')}>Colours</button>
</div>
{tab === 'general' && (
<>
{/* App Logo */}
<div style={{ marginBottom: 24 }}>
<div className="settings-section-label">App Logo</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{
width: 72, height: 72, borderRadius: 16, background: 'var(--background)',
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0
}}>
<img src={settings.logo_url || '/icons/jama.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
</div>
<div>
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
Upload Logo
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
</label>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>
Square format, max 1MB. Used in sidebar, login page and browser tab.
</p>
</div>
</div>
</div>
{/* App Name */}
<div style={{ marginBottom: 24 }}>
<div className="settings-section-label">App Name</div>
<div style={{ display: 'flex', gap: 8 }}>
<input className="input flex-1" value={appName} onChange={e => setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>{loading ? '...' : 'Save'}</button>
</div>
</div>
{/* Reset */}
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
<div className="settings-section-label">Reset</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
{!showResetConfirm ? (
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>Reset All to Defaults</button>
) : (
<div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '12px 14px' }}>
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
This will reset the app name, logo and all colours to their install defaults. This cannot be undone.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>Cancel</button>
</div>
</div>
)}
{settings.app_version && (
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>v{settings.app_version}</span>
)}
</div>
</div>
{settings.pw_reset_active === 'true' && (
<div className="warning-banner">
<span></span>
<span><strong>ADMPW_RESET is active.</strong> The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.</span>
</div>
)}
</>
)}
{tab === 'colours' && (
<div className="flex-col gap-3">
<div>
<div className="settings-section-label">App Title Colour</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 }}>
<TitleColourRow
bgColor="#f1f3f4"
bgLabel="Light mode"
textColor={colourTitle}
onChange={setColourTitle}
/>
<TitleColourRow
bgColor="#13131f"
bgLabel="Dark mode"
textColor={colourTitleDark}
onChange={setColourTitleDark}
/>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<ColourPicker
label="Public Message Avatar Colour"
value={colourPublic}
onChange={setColourPublic}
preview={(val) => (
<div style={{
width: 36, height: 36, borderRadius: '50%', background: val,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
}}>A</div>
)}
/>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<ColourPicker
label="Direct Message Avatar Colour"
value={colourDm}
onChange={setColourDm}
preview={(val) => (
<div style={{
width: 36, height: 36, borderRadius: '50%', background: val,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
}}>B</div>
)}
/>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<button className="btn btn-primary" onClick={handleSaveColours} disabled={savingColours}>
{savingColours ? 'Saving...' : 'Save Colours'}
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
.chat-window {
flex: 1;
display: flex;
flex-direction: column;
background: var(--surface-variant);
overflow: hidden;
min-width: 0;
min-height: 0;
height: 100%;
}
.chat-window.empty {
align-items: center;
justify-content: center;
}
.empty-state {
text-align: center;
color: var(--text-secondary);
}
.empty-icon {
margin-bottom: 16px;
opacity: 0.3;
}
.empty-state h3 {
font-size: 18px;
margin-bottom: 8px;
color: var(--text-primary);
}
.empty-state p { font-size: 14px; }
/* Header */
.chat-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: white;
border-bottom: 1px solid var(--border);
min-height: 64px;
position: relative;
z-index: 10;
}
.group-icon-sm {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.chat-header-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.chat-header-sub {
font-size: 12px;
color: var(--text-secondary);
}
/* Real name in brackets in DM header */
.chat-header-real-name {
font-size: 12px;
font-weight: 400;
color: var(--text-tertiary);
}
.readonly-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #fff3e0;
color: #e65100;
font-weight: 500;
}
/* Messages */
.messages-container {
flex: 1;
min-height: 0; /* critical: allows flex child to shrink below content size */
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
display: flex;
flex-direction: column;
gap: 2px;
scroll-padding-bottom: 0;
overscroll-behavior: contain;
align-items: stretch;
}
/* Cap message width and centre on wide screens */
.messages-container > * {
max-width: 1024px;
width: 100%;
align-self: center;
box-sizing: border-box;
}
.load-more-btn {
align-self: center;
font-size: 13px;
color: var(--primary);
padding: 8px 16px;
border-radius: 20px;
background: var(--primary-light);
margin-bottom: 8px;
transition: var(--transition);
}
.load-more-btn:hover { background: #d2e3fc; }
/* Typing indicator */
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-secondary);
}
.dots {
display: flex;
gap: 3px;
}
.dots span {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--text-tertiary);
animation: bounce 1.2s infinite;
}
.dots span:nth-child(2) { animation-delay: 0.2s; }
.dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-5px); }
}
/* Readonly bar */
.readonly-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
background: white;
border-top: 1px solid var(--border);
font-size: 14px;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,319 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Message from './Message.jsx';
import MessageInput from './MessageInput.jsx';
import { api } from '../utils/api.js';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { useSocket } from '../contexts/SocketContext.jsx';
import './ChatWindow.css';
import GroupInfoModal from './GroupInfoModal.jsx';
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onlineUserIds = new Set() }) {
const { user: currentUser } = useAuth();
const { socket } = useSocket();
const { toast } = useToast();
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [typing, setTyping] = useState([]);
const [iconGroupInfo, setIconGroupInfo] = useState('');
const [avatarColors, setAvatarColors] = useState({ public: '#1a73e8', dm: '#a142f4' });
const [showInfo, setShowInfo] = useState(false);
const [replyTo, setReplyTo] = useState(null);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const typingTimers = useRef({});
useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
api.getSettings().then(({ settings }) => {
setIconGroupInfo(settings.icon_groupinfo || '');
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
}).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => {
setIconGroupInfo(settings.icon_groupinfo || '');
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
}).catch(() => {});
window.addEventListener('jama:settings-updated', handler);
window.addEventListener('jama:settings-changed', handler);
return () => {
window.removeEventListener('jama:settings-updated', handler);
window.removeEventListener('jama:settings-changed', handler);
};
}, []);
const scrollToBottom = useCallback((smooth = false) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, []);
useEffect(() => {
if (!group) { setMessages([]); return; }
setMessages([]);
setHasMore(false);
setLoading(true);
api.getMessages(group.id)
.then(({ messages }) => {
setMessages(messages);
setHasMore(messages.length >= 50);
setTimeout(() => scrollToBottom(), 50);
})
.catch(e => toast(e.message, 'error'))
.finally(() => setLoading(false));
}, [group?.id]);
// Socket events
useEffect(() => {
if (!socket || !group) return;
const handleNew = (msg) => {
if (msg.group_id !== group.id) return;
setMessages(prev => {
if (prev.find(m => m.id === msg.id)) return prev;
return [...prev, msg];
});
setTimeout(() => scrollToBottom(true), 50);
};
const handleDeleted = ({ messageId }) => {
setMessages(prev => prev.map(m =>
m.id === messageId ? { ...m, is_deleted: 1, content: null, image_url: null } : m
));
};
const handleReaction = ({ messageId, reactions }) => {
setMessages(prev => prev.map(m =>
m.id === messageId ? { ...m, reactions } : m
));
};
const handleTypingStart = ({ userId: tid, user: tu }) => {
if (tid === currentUser?.id) return;
setTyping(prev => prev.find(t => t.userId === tid)
? prev
: [...prev, { userId: tid, name: tu?.display_name || tu?.name || 'Someone' }]);
if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]);
typingTimers.current[tid] = setTimeout(() => {
setTyping(prev => prev.filter(t => t.userId !== tid));
}, 4000);
};
const handleTypingStop = ({ userId: tid }) => {
clearTimeout(typingTimers.current[tid]);
setTyping(prev => prev.filter(t => t.userId !== tid));
};
const handleGroupUpdated = (updatedGroup) => {
if (updatedGroup.id === group.id) onGroupUpdated?.();
};
socket.on('message:new', handleNew);
socket.on('message:deleted', handleDeleted);
socket.on('reaction:updated', handleReaction);
socket.on('typing:start', handleTypingStart);
socket.on('typing:stop', handleTypingStop);
socket.on('group:updated', handleGroupUpdated);
return () => {
socket.off('message:new', handleNew);
socket.off('message:deleted', handleDeleted);
socket.off('reaction:updated', handleReaction);
socket.off('typing:start', handleTypingStart);
socket.off('typing:stop', handleTypingStop);
socket.off('group:updated', handleGroupUpdated);
};
}, [socket, group?.id, currentUser?.id]);
const handleLoadMore = async () => {
if (!hasMore || loading || messages.length === 0) return;
const container = messagesContainerRef.current;
const prevScrollHeight = container?.scrollHeight || 0;
setLoading(true);
try {
const oldest = messages[0];
const { messages: older } = await api.getMessages(group.id, oldest.id);
setMessages(prev => [...older, ...prev]);
setHasMore(older.length >= 50);
requestAnimationFrame(() => {
if (container) container.scrollTop = container.scrollHeight - prevScrollHeight;
});
} catch (e) {
toast(e.message, 'error');
} finally {
setLoading(false);
}
};
const handleSend = async ({ content, imageFile, linkPreview, emojiOnly }) => {
if ((!content?.trim() && !imageFile) || !group) return;
const replyToId = replyTo?.id || null;
setReplyTo(null);
try {
if (imageFile) {
await api.uploadImage(group.id, imageFile, { replyToId, content: content?.trim() || '' });
} else {
await api.sendMessage(group.id, { content: content.trim(), replyToId, linkPreview, emojiOnly });
}
} catch (e) {
toast(e.message || 'Failed to send', 'error');
}
};
const handleDelete = async (msgId) => {
try {
await api.deleteMessage(msgId);
} catch (e) {
toast(e.message || 'Could not delete', 'error');
}
};
const handleReact = async (msgId, emoji) => {
try {
await api.toggleReaction(msgId, emoji);
} catch (e) {
toast(e.message || 'Could not react', 'error');
}
};
const handleReply = (msg) => {
setReplyTo(msg);
};
const handleDirectMessage = (dmGroup) => {
onDirectMessage?.(dmGroup);
};
if (!group) {
return (
<div className="chat-window empty">
<div className="empty-state">
<div className="empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
</div>
<h3>Select a conversation</h3>
<p>Choose a channel or direct message to start chatting</p>
</div>
</div>
);
}
const isDirect = !!group.is_direct;
const peerName = group.peer_display_name
? <>{group.peer_display_name}<span className="chat-header-real-name"> ({group.peer_real_name})</span></>
: group.peer_real_name || group.name;
const isOnline = isDirect && group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false);
return (
<>
<div className="chat-window">
{/* Header */}
<div className="chat-header">
{isMobile && onBack && (
<button className="btn-icon" onClick={onBack} style={{ marginRight: 4 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
)}
{isDirect && group.peer_avatar ? (
<div style={{ position: 'relative', flexShrink: 0 }}>
<img src={group.peer_avatar} alt={group.name} className="group-icon-sm" style={{ objectFit: 'cover', padding: 0 }} />
{isOnline && <span className="online-dot" style={{ position: 'absolute', bottom: 1, right: 1 }} />}
</div>
) : (
<div className="group-icon-sm" style={{ background: group.type === 'public' ? avatarColors.public : avatarColors.dm, flexShrink: 0 }}>
{group.type === 'public' ? '#' : isDirect ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 overflow-hidden">
<div className="chat-header-name truncate">
{isDirect ? peerName : group.name}
{group.is_readonly ? <span className="readonly-badge" style={{ marginLeft: 8 }}>read-only</span> : null}
</div>
{isDirect && isOnline && <div className="chat-header-sub" style={{ color: 'var(--success)' }}>Online</div>}
{!isDirect && group.type === 'private' && <div className="chat-header-sub">Private group</div>}
</div>
<button
className="btn-icon"
onClick={() => setShowInfo(true)}
title="Conversation info"
>
{iconGroupInfo ? (
<img src={iconGroupInfo} alt="info" style={{ width: 22, height: 22, objectFit: 'contain' }} />
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="22" height="22">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
</svg>
)}
</button>
</div>
{/* Messages */}
<div className="messages-container" ref={messagesContainerRef}>
{hasMore && (
<button className="load-more-btn" onClick={handleLoadMore} disabled={loading}>
{loading ? 'Loading…' : 'Load older messages'}
</button>
)}
{messages.map((msg, i) => (
<Message
key={msg.id}
message={msg}
prevMessage={messages[i - 1]}
currentUser={currentUser}
onReply={handleReply}
onDelete={handleDelete}
onReact={handleReact}
onDirectMessage={handleDirectMessage}
isDirect={isDirect}
onlineUserIds={onlineUserIds}
/>
))}
{typing.length > 0 && (
<div className="typing-indicator">
<span>{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing</span>
<div className="dots"><span /><span /><span /></div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
{group.is_readonly && currentUser?.role !== 'admin' ? (
<div className="readonly-bar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
This channel is read-only
</div>
) : (
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} />
)}
</div>
{showInfo && (
<GroupInfoModal
group={group}
onClose={() => setShowInfo(false)}
onUpdated={(updatedGroup) => { setShowInfo(false); onGroupUpdated && onGroupUpdated(updatedGroup); }}
onBack={() => setShowInfo(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,51 @@
import { useState, useEffect } from 'react';
import { useSocket } from '../contexts/SocketContext.jsx';
import { api } from '../utils/api.js';
export default function GlobalBar({ isMobile, showSidebar }) {
const { connected } = useSocket();
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
useEffect(() => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
window.addEventListener('jama:settings-changed', handler);
// Re-render when theme changes so title colour switches correctly
const themeObserver = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => {
window.removeEventListener('jama:settings-changed', handler);
themeObserver.disconnect();
};
}, []);
const appName = settings.app_name || 'jama';
const logoUrl = settings.logo_url;
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null;
// On mobile: show bar only when sidebar is visible (chat list view)
// On desktop: always show
if (isMobile && !showSidebar) return null;
return (
<div className="global-bar">
<div className="global-bar-brand">
<img
src={logoUrl || '/icons/jama.png'}
alt={appName}
className="global-bar-logo"
/>
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
</div>
{!connected && (
<span className="global-bar-offline" title="Offline">
<span className="offline-dot" />
<span className="offline-label">Offline</span>
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,259 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import Avatar from './Avatar.jsx';
export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
const { user } = useAuth();
const toast = useToast();
const [members, setMembers] = useState([]);
const [editing, setEditing] = useState(false);
const [newName, setNewName] = useState(group.name);
const [addSearch, setAddSearch] = useState('');
const [addResults, setAddResults] = useState([]);
const [customName, setCustomName] = useState(group.owner_name_original ? group.name : '');
const [savedCustomName, setSavedCustomName] = useState(group.owner_name_original ? group.name : '');
const [savingCustom, setSavingCustom] = useState(false);
const isDirect = !!group.is_direct;
const isOwner = group.owner_id === user.id;
const isAdmin = user.role === 'admin';
const canManage = !isDirect && ((group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin));
const canRename = !isDirect && !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner));
useEffect(() => {
if (group.type === 'private') {
api.getMembers(group.id).then(({ members }) => setMembers(members)).catch(() => {});
}
}, [group.id]);
const handleCustomName = async () => {
setSavingCustom(true);
try {
const saved = customName.trim();
await api.setCustomGroupName(group.id, saved);
setSavedCustomName(saved);
toast(saved ? 'Custom name saved' : 'Custom name removed', 'success');
onUpdated();
} catch (e) {
toast(e.message, 'error');
} finally {
setSavingCustom(false);
}
};
useEffect(() => {
if (addSearch) {
api.searchUsers(addSearch).then(({ users }) => setAddResults(users)).catch(() => {});
}
}, [addSearch]);
const handleRename = async () => {
if (!newName.trim() || newName === group.name) { setEditing(false); return; }
try {
await api.renameGroup(group.id, newName.trim());
toast('Renamed', 'success');
onUpdated();
setEditing(false);
} catch (e) { toast(e.message, 'error'); }
};
const handleLeave = async () => {
if (!confirm('Leave this message?')) return;
try {
await api.leaveGroup(group.id);
toast('Left message', 'success');
onClose();
if (isDirect) {
// For direct messages: socket group:deleted fired by server handles
// removing from sidebar and clearing active group — no manual refresh needed
} else {
onUpdated();
if (onBack) onBack();
}
} catch (e) { toast(e.message, 'error'); }
};
const handleTakeOwnership = async () => {
if (!confirm('Take ownership of this private group?')) return;
try {
await api.takeOwnership(group.id);
toast('Ownership taken', 'success');
onUpdated();
onClose();
} catch (e) { toast(e.message, 'error'); }
};
const handleAdd = async (u) => {
try {
await api.addMember(group.id, u.id);
toast(`${u.name} added`, 'success');
api.getMembers(group.id).then(({ members }) => setMembers(members));
setAddSearch('');
setAddResults([]);
} catch (e) { toast(e.message, 'error'); }
};
const handleRemove = async (member) => {
if (!confirm(`Remove ${member.name}?`)) return;
try {
await api.removeMember(group.id, member.id);
toast(`${member.name} removed`, 'success');
setMembers(prev => prev.filter(m => m.id !== member.id));
} catch (e) { toast(e.message, 'error'); }
};
const handleDelete = async () => {
if (!confirm('Delete this message? This cannot be undone.')) return;
try {
await api.deleteGroup(group.id);
toast('Deleted', 'success');
onUpdated();
onClose();
if (onBack) onBack();
} catch (e) { toast(e.message, 'error'); }
};
// For direct messages: only show Delete button (owner = remaining user after other left)
const canDeleteDirect = isDirect && isOwner;
const canDeleteRegular = !isDirect && (isOwner || (isAdmin && group.type === 'public')) && !group.is_default;
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal">
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Message Info</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* Name */}
<div style={{ marginBottom: 16 }}>
{editing ? (
<div className="flex gap-2">
<input className="input flex-1" value={newName} onChange={e => setNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleRename()} />
<button className="btn btn-primary btn-sm" onClick={handleRename}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
</div>
) : (
<div className="flex items-center gap-8" style={{ gap: 12 }}>
<h3 style={{ fontSize: 18, fontWeight: 600, flex: 1 }}>{group.name}</h3>
{canRename && (
<button className="btn-icon" onClick={() => setEditing(true)} title="Rename">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
)}
</div>
)}
<div className="flex items-center gap-6" style={{ gap: 8, marginTop: 4 }}>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{isDirect ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
</span>
{!!group.is_readonly && <span className="readonly-badge" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: '#fff3e0', color: '#e65100' }}>Read-only</span>}
</div>
</div>
{/* Custom name — any user can set their own display name for this group */}
<div style={{ marginBottom: 16 }}>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 6 }}>
Your custom name <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(only visible to you)</span>
</label>
<div className="flex gap-2">
<input
className="input flex-1"
value={customName}
onChange={e => setCustomName(e.target.value)}
placeholder={group.owner_name_original || group.name}
onKeyDown={e => e.key === 'Enter' && handleCustomName()}
/>
{customName.trim() !== savedCustomName ? (
<button className="btn btn-primary btn-sm" onClick={handleCustomName} disabled={savingCustom}>
Save
</button>
) : savedCustomName ? (
<button className="btn btn-sm" style={{ background: 'var(--surface-variant)', color: 'var(--text-secondary)' }}
onClick={() => { setCustomName(''); }}
disabled={savingCustom}>
Remove
</button>
) : null}
</div>
{group.owner_name_original && (
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 4 }}>
Showing as: <strong>{customName.trim() || group.owner_name_original}</strong>
{customName.trim() && <span> ({group.owner_name_original})</span>}
</p>
)}
</div>
{/* Members — shown for private non-direct groups */}
{group.type === 'private' && !isDirect && (
<div style={{ marginBottom: 16 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
Members ({members.length})
</div>
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
{members.map(m => (
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
<Avatar user={m} size="sm" />
<span className="flex-1 text-sm">{m.name}</span>
{m.id === group.owner_id && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
{canManage && m.id !== group.owner_id && (
<button
onClick={() => handleRemove(m)}
title="Remove"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4, lineHeight: 1, transition: 'color var(--transition)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</div>
))}
</div>
{canManage && (
<div style={{ marginTop: 12 }}>
<input className="input" placeholder="Search to add member..." value={addSearch} onChange={e => setAddSearch(e.target.value)} />
{addResults.length > 0 && addSearch && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', marginTop: 4, maxHeight: 150, overflowY: 'auto', background: 'var(--surface)' }}>
{addResults.filter(u => !members.find(m => m.id === u.id)).map(u => (
<button key={u.id} className="flex items-center gap-2 w-full" style={{ gap: 10, padding: '8px 12px', textAlign: 'left', transition: 'background var(--transition)', color: 'var(--text-primary)' }} onClick={() => handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
<Avatar user={u} size="sm" />
<span className="text-sm flex-1">{u.name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex-col gap-2">
{/* Direct message: leave (if not already owner/last person) */}
{isDirect && !isOwner && (
<button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Conversation</button>
)}
{/* Regular private: leave if not owner */}
{!isDirect && group.type === 'private' && !isOwner && (
<button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Group</button>
)}
{/* Admin take ownership (non-direct only) */}
{!isDirect && isAdmin && group.type === 'private' && !isOwner && (
<button className="btn btn-secondary w-full" onClick={handleTakeOwnership}>Take Ownership (Admin)</button>
)}
{/* Delete */}
{(canDeleteDirect || canDeleteRegular) && (
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useState, useEffect } from 'react';
import { marked } from 'marked';
import { api } from '../utils/api.js';
// Configure marked for safe rendering
marked.setOptions({ breaks: true, gfm: true });
export default function HelpModal({ onClose, dismissed: initialDismissed }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [dismissed, setDismissed] = useState(!!initialDismissed);
useEffect(() => {
api.getHelp()
.then(({ content }) => setContent(content))
.catch(() => setContent('# Getting Started\n\nHelp content could not be loaded.'))
.finally(() => setLoading(false));
}, []);
const handleDismissToggle = async (e) => {
const val = e.target.checked;
setDismissed(val);
try {
await api.dismissHelp(val);
} catch (_) {}
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal help-modal">
{/* Header */}
<div className="flex items-center justify-between" style={{ marginBottom: 16 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Getting Started</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/* Scrollable markdown content */}
<div className="help-content">
{loading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-tertiary)' }}>Loading</div>
) : (
<div
className="help-markdown"
dangerouslySetInnerHTML={{ __html: marked.parse(content) }}
/>
)}
</div>
{/* Footer */}
<div className="help-footer">
<label className="flex items-center gap-2 text-sm" style={{ cursor: 'pointer', color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={dismissed}
onChange={handleDismissToggle}
/>
Do not show again at login
</label>
<button className="btn btn-primary btn-sm" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
export default function ImageLightbox({ src, onClose }) {
const overlayRef = useRef(null);
// Close on Escape
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
// Prevent body scroll while open
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', handler);
document.body.style.overflow = '';
};
}, [onClose]);
return createPortal(
<div
ref={overlayRef}
onClick={(e) => e.target === overlayRef.current && onClose()}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.92)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
touchAction: 'pinch-zoom',
}}
>
{/* Close button */}
<button
onClick={onClose}
style={{
position: 'absolute', top: 16, right: 16,
background: 'rgba(255,255,255,0.15)', border: 'none',
borderRadius: '50%', width: 40, height: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'white', zIndex: 10000,
}}
title="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
{/* Download button */}
<a
href={src}
download
style={{
position: 'absolute', top: 16, right: 64,
background: 'rgba(255,255,255,0.15)', border: 'none',
borderRadius: '50%', width: 40, height: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'white', zIndex: 10000, textDecoration: 'none',
}}
title="Download"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</a>
{/* Image — fit to screen, browser handles pinch-zoom natively */}
<img
src={src}
alt="Full size"
style={{
maxWidth: '92vw',
maxHeight: '92vh',
objectFit: 'contain',
borderRadius: 8,
userSelect: 'none',
touchAction: 'pinch-zoom',
boxShadow: '0 8px 40px rgba(0,0,0,0.6)',
}}
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body
);
}

View File

@@ -0,0 +1,336 @@
.date-separator {
display: flex;
align-items: center;
justify-content: center;
margin: 12px 0 8px;
}
.date-separator span {
background: rgba(0,0,0,0.06);
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
}
.system-message {
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
font-style: italic;
margin: 6px 0;
padding: 0 24px;
}
[data-theme="dark"] .system-message {
color: var(--text-secondary);
}
.msg-link {
color: var(--primary);
text-decoration: underline;
word-break: break-all;
}
.msg-link:hover {
opacity: 0.8;
}
/* Own bubble (primary background) — link must be white */
.msg-bubble.out .msg-link {
color: white;
text-decoration: underline;
opacity: 0.9;
}
.msg-bubble.out .msg-link:hover {
opacity: 1;
}
/* Incoming bubble — link should be a dark/contrasting tone, not the same blue as bubble */
.msg-bubble.in .msg-link {
color: var(--primary-dark, #1565c0);
text-decoration: underline;
}
.message-wrapper {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 1px 0;
position: relative;
}
.message-wrapper.own { flex-direction: row-reverse; }
.message-wrapper.grouped { margin-top: 2px; }
.message-wrapper:not(.grouped) { margin-top: 10px; }
.avatar-spacer { width: 32px; flex-shrink: 0; }
.msg-avatar { flex-shrink: 0; }
.message-body {
display: flex;
flex-direction: column;
max-width: 65%;
min-width: 0;
}
.own .message-body { align-items: flex-end; }
.msg-name {
font-size: calc(0.75rem * var(--font-scale));
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 3px;
padding: 0 12px;
}
/* Reply preview */
.reply-preview {
display: flex;
gap: 8px;
background: rgba(0,0,0,0.05);
border-radius: 8px 8px 0 0;
padding: 6px 10px;
margin-bottom: -4px;
max-width: 280px;
}
.reply-bar { width: 3px; background: var(--primary); border-radius: 2px; flex-shrink: 0; }
.reply-name { font-size: calc(0.6875rem * var(--font-scale)); font-weight: 600; color: var(--primary); }
.reply-text { font-size: calc(0.75rem * var(--font-scale)); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
/* Bubble row */
.msg-bubble-wrap {
position: relative;
display: flex;
align-items: flex-start;
gap: 6px;
}
.own .msg-bubble-wrap {
position: relative; flex-direction: row-reverse; }
/* Wrapper that holds the actions toolbar + bubble together */
.msg-bubble-with-actions {
position: relative;
display: flex;
flex-direction: column;
}
/* Actions toolbar — floats above the bubble */
.msg-actions {
display: flex;
align-items: center;
gap: 2px;
background: white;
border-radius: 20px;
padding: 4px 6px;
box-shadow: var(--shadow-md);
position: absolute;
top: -36px;
z-index: 20;
white-space: nowrap;
}
/* Own messages: toolbar anchors to the right edge of bubble */
.msg-actions.actions-left { right: 0; }
/* Other messages: toolbar anchors to the left edge of bubble */
.msg-actions.actions-right { left: 0; }
.quick-emoji {
font-size: 16px;
padding: 4px;
border-radius: 50%;
transition: var(--transition);
cursor: pointer;
line-height: 1;
}
.quick-emoji:hover { background: var(--background); transform: scale(1.2); }
.action-btn {
width: 28px;
height: 28px;
color: var(--text-secondary);
}
.action-btn:hover { color: var(--text-primary); }
.action-btn.danger:hover { color: var(--error); }
/* Emoji picker — anchored relative to the toolbar */
.emoji-picker-wrap {
position: absolute;
top: -360px; /* above the toolbar by default */
z-index: 100;
}
.emoji-picker-wrap.picker-right { left: 0; }
.emoji-picker-wrap.picker-left { right: 0; }
/* When message is near top of window, open picker downward instead */
.emoji-picker-wrap.picker-down {
top: 36px;
}
/* Bubble */
.msg-bubble {
padding: 8px 12px;
border-radius: 18px;
max-width: 100%;
word-break: break-word;
position: relative;
}
@media (max-width: 767px) {
.msg-bubble {
user-select: none;
-webkit-user-select: none;
}
}
.msg-bubble.out {
background: var(--primary);
color: white;
border-bottom-right-radius: 4px;
}
.msg-bubble.in {
background: var(--bubble-in);
color: var(--text-primary);
border-bottom-left-radius: 4px;
box-shadow: var(--shadow-sm);
}
.msg-bubble.deleted {
background: transparent !important;
border: 1px dashed var(--border);
}
.deleted-text { font-size: calc(0.8125rem * var(--font-scale)); color: var(--text-tertiary); font-style: italic; }
.msg-text {
font-size: calc(0.875rem * var(--font-scale));
line-height: 1.5;
white-space: pre-wrap;
}
.mention {
color: #1a5ca8;
font-weight: 600;
background: rgba(26,92,168,0.1);
border-radius: 3px;
padding: 0 2px;
}
/* Sender bubble — primary colour is the background, so mention must contrast against it */
.out .mention {
color: #ffffff;
background: rgba(255,255,255,0.22);
}
.msg-image {
max-width: 240px;
max-height: 240px;
border-radius: 12px;
display: block;
cursor: pointer;
object-fit: cover;
}
.msg-time {
font-size: calc(0.6875rem * var(--font-scale));
color: var(--text-tertiary);
white-space: nowrap;
flex-shrink: 0;
padding-bottom: 4px;
}
/* Reactions */
.reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
padding: 0 4px;
}
.reaction-btn {
display: flex;
align-items: center;
gap: 3px;
padding: 3px 8px;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--border);
font-size: calc(0.875rem * var(--font-scale));
cursor: pointer;
transition: var(--transition);
}
.reaction-count { font-size: calc(0.75rem * var(--font-scale)); color: var(--text-secondary); }
.reaction-btn.active { background: var(--primary-light); border-color: var(--primary); }
.reaction-btn.active .reaction-count { color: var(--primary); }
.reaction-btn:hover { background: var(--primary-light); }
.reaction-remove {
font-size: 13px;
color: var(--primary);
font-weight: 700;
margin-left: 1px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
}
.reaction-btn:hover .reaction-remove { opacity: 1; }
/* Link preview */
.link-preview {
display: flex;
gap: 10px;
background: rgba(0,0,0,0.06);
border-radius: 10px;
padding: 10px;
margin-top: 6px;
text-decoration: none;
max-width: 280px;
overflow: hidden;
transition: var(--transition);
}
.link-preview:hover { background: rgba(0,0,0,0.1); }
.link-preview-img {
width: 60px;
height: 60px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.link-preview-content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.link-site { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
.link-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.link-desc { font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.out .link-preview { background: rgba(255,255,255,0.15); }
.out .link-title { color: white; }
.out .link-desc { color: rgba(255,255,255,0.8); }
/* Emoji-only messages: no bubble background, large size */
.msg-bubble.emoji-only {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 2px 4px;
}
.msg-bubble.emoji-only::after { display: none; }
.msg-text.emoji-msg {
font-size: 3em;
line-height: 1.1;
margin: 0;
user-select: text;
}

View File

@@ -0,0 +1,344 @@
import { useState, useRef, useEffect } from 'react';
import Avatar from './Avatar.jsx';
import UserProfilePopup from './UserProfilePopup.jsx';
import ImageLightbox from './ImageLightbox.jsx';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
import { parseTS } from '../utils/api.js';
import './Message.css';
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
function formatMsgContent(content) {
if (!content) return '';
// First handle @mentions
let html = content.replace(/@\[([^\]]+)\]/g, (_, name) => `<span class="mention">@${name}</span>`);
// Then linkify bare URLs (not already inside a tag)
html = html.replace(/(https?:\/\/[^\s<>"]+)/g, (url) => {
// Trim trailing punctuation that's unlikely to be part of the URL
const trimmed = url.replace(/[.,!?;:)\]]+$/, '');
const trailing = url.slice(trimmed.length);
return `<a href="${trimmed}" target="_blank" rel="noopener noreferrer" class="msg-link">${trimmed}</a>${trailing}`;
});
return html;
}
// Detect emoji-only messages for large rendering
function isEmojiOnly(str) {
if (!str || str.length > 12) return false;
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u;
return emojiRegex.test(str.trim());
}
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect, onlineUserIds = new Set() }) {
const [showActions, setShowActions] = useState(false);
const [showOptionsMenu, setShowOptionsMenu] = useState(false);
const longPressTimer = useRef(null);
const optionsMenuRef = useRef(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const wrapperRef = useRef(null);
const pickerRef = useRef(null);
const avatarRef = useRef(null);
const [showProfile, setShowProfile] = useState(false);
const [lightboxSrc, setLightboxSrc] = useState(null);
const [pickerOpensDown, setPickerOpensDown] = useState(false);
const isOwn = msg.user_id === currentUser.id;
const isDeleted = !!msg.is_deleted;
const isSystem = msg.type === 'system';
// These must be computed before any early returns that reference them
const showDateSep = !prevMessage ||
parseTS(msg.created_at).toDateString() !== parseTS(prevMessage.created_at).toDateString();
const prevSameUser = !showDateSep && prevMessage &&
prevMessage.user_id === msg.user_id &&
prevMessage.type !== 'system' && msg.type !== 'system';
const canDelete = !msg.is_deleted && (
msg.user_id === currentUser.id ||
currentUser.role === 'admin' ||
msg.group_owner_id === currentUser.id
);
// Close emoji picker when clicking outside
useEffect(() => {
if (!showEmojiPicker) return;
const handler = (e) => {
if (pickerRef.current && !pickerRef.current.contains(e.target)) {
setShowEmojiPicker(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showEmojiPicker]);
// Close options menu on outside click
useEffect(() => {
if (!showOptionsMenu) return;
const close = (e) => {
if (optionsMenuRef.current && !optionsMenuRef.current.contains(e.target)) {
setShowOptionsMenu(false);
}
};
document.addEventListener('mousedown', close);
document.addEventListener('touchstart', close);
return () => {
document.removeEventListener('mousedown', close);
document.removeEventListener('touchstart', close);
};
}, [showOptionsMenu]);
const handleReact = (emoji) => {
onReact(msg.id, emoji);
setShowEmojiPicker(false);
};
const handleCopy = () => {
if (!msg.content) return;
navigator.clipboard.writeText(msg.content).catch(() => {});
};
const handleTogglePicker = () => {
if (!showEmojiPicker && wrapperRef.current) {
const rect = wrapperRef.current.getBoundingClientRect();
setPickerOpensDown(rect.top < 400);
}
setShowEmojiPicker(p => !p);
};
// Long press for mobile action menu (DMs only)
const handleTouchStart = () => {
if (!isDirect) return;
longPressTimer.current = setTimeout(() => setShowOptionsMenu(true), 500);
};
const handleTouchEnd = () => {
if (longPressTimer.current) clearTimeout(longPressTimer.current);
};
// Deleted messages are filtered out by ChatWindow, but guard here too
if (isDeleted) return null;
// System messages render as a simple centred notice
if (isSystem) {
return (
<>
{showDateSep && (
<div className="date-separator"><span>{formatDate(msg.created_at)}</span></div>
)}
<div className="system-message">{msg.content}</div>
</>
);
}
const reactionMap = {};
for (const r of (msg.reactions || [])) {
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, users: [], hasMe: false };
reactionMap[r.emoji].count++;
reactionMap[r.emoji].users.push(r.user_name);
if (r.user_id === currentUser.id) reactionMap[r.emoji].hasMe = true;
}
const msgUser = {
id: msg.user_id,
name: msg.user_name,
display_name: msg.user_display_name,
avatar: msg.user_avatar,
role: msg.user_role,
status: msg.user_status,
hide_admin_tag: msg.user_hide_admin_tag,
about_me: msg.user_about_me,
allow_dm: msg.user_allow_dm,
};
return (
<>
{showDateSep && (
<div className="date-separator">
<span>{formatDate(msg.created_at)}</span>
</div>
)}
<div
ref={wrapperRef}
className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`}
>
{!isOwn && !prevSameUser && (
<div
ref={avatarRef}
style={{ position: 'relative', cursor: 'pointer', transition: 'box-shadow 0.15s', flexShrink: 0, borderRadius: '50%', display: 'inline-flex' }}
onClick={() => setShowProfile(p => !p)}
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
<Avatar user={msgUser} size="sm" className="msg-avatar" />
{!!(onlineUserIds instanceof Set ? onlineUserIds.has(Number(msg.user_id)) : false) && (
<span style={{
position: 'absolute', bottom: 0, right: 0,
width: 9, height: 9, borderRadius: '50%',
background: '#34a853', border: '2px solid var(--surface)',
pointerEvents: 'none'
}} />
)}
</div>
)}
{!isOwn && prevSameUser && <div className="avatar-spacer" />}
<div className="message-body">
{!isOwn && !prevSameUser && (
<div className="msg-name">
{msgUser.display_name || msgUser.name}
{msgUser.role === 'admin' && !msgUser.hide_admin_tag && <span className="role-badge role-admin" style={{ marginLeft: 6 }}>Admin</span>}
{msgUser.status !== 'active' && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--text-tertiary)' }}>(inactive)</span>}
</div>
)}
{/* Reply preview */}
{msg.reply_to_id && (
<div className="reply-preview">
<div className="reply-bar" />
<div>
<div className="reply-name">{msg.reply_user_display_name || msg.reply_user_name}</div>
<div className="reply-text">
{msg.reply_is_deleted ? <em style={{ color: 'var(--text-tertiary)' }}>Deleted message</em>
: msg.reply_image_url ? '📷 Image'
: msg.reply_content}
</div>
</div>
</div>
)}
{/* Bubble + actions together so actions hover above bubble */}
<div className="msg-bubble-wrap">
<div className="msg-bubble-with-actions"
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => { if (!showEmojiPicker && !showOptionsMenu) setShowActions(false); }}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchEnd}
onContextMenu={isDirect ? (e => { e.preventDefault(); setShowOptionsMenu(true); }) : undefined}
>
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
{!isDeleted && (showActions || showEmojiPicker) && (
<div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}>
{QUICK_EMOJIS.map(e => (
<button key={e} className="quick-emoji" onClick={() => handleReact(e)} title={e}>{e}</button>
))}
<button className="btn-icon action-btn" onClick={handleTogglePicker} title="More reactions">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
</button>
<button className="btn-icon action-btn" onClick={() => onReply(msg)} title="Reply">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
</button>
{msg.content && (
<button className="btn-icon action-btn" onClick={handleCopy} title="Copy text">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
)}
{canDelete && (
<button className="btn-icon action-btn danger" onClick={() => onDelete(msg.id)} title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
)}
{/* Emoji picker anchored to the toolbar */}
{showEmojiPicker && (
<div
className={`emoji-picker-wrap ${isOwn ? 'picker-left' : 'picker-right'} ${pickerOpensDown ? 'picker-down' : ''}`}
ref={pickerRef}
onMouseDown={e => e.stopPropagation()}
>
<Picker data={data} onEmojiSelect={(e) => handleReact(e.native)} theme="light" previewPosition="none" skinTonePosition="none" />
</div>
)}
</div>
)}
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}${!msg.image_url && isEmojiOnly(msg.content) ? ' emoji-only' : ''}`}>
{msg.image_url && (
<img
src={msg.image_url}
alt="attachment"
className="msg-image"
onClick={() => setLightboxSrc(msg.image_url)}
/>
)}
{msg.content && (
isEmojiOnly(msg.content) && !msg.image_url
? <p className="msg-text emoji-msg">{msg.content}</p>
: <p
className="msg-text"
dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }}
/>
)}
{msg.link_preview && <LinkPreview data={msg.link_preview} />}
</div>
</div>
<span className="msg-time">{formatTime(msg.created_at)}</span>
</div>
{Object.keys(reactionMap).length > 0 && (
<div className="reactions">
{Object.entries(reactionMap).map(([emoji, { count, users, hasMe }]) => (
<button
key={emoji}
className={`reaction-btn ${hasMe ? 'active' : ''}`}
onClick={() => onReact(msg.id, emoji)}
title={hasMe ? `${users.join(', ')} · Click to remove` : users.join(', ')}
>
{emoji} <span className="reaction-count">{count}</span>
{hasMe && <span className="reaction-remove" title="Remove reaction">×</span>}
</button>
))}
</div>
)}
</div>
</div>
{showProfile && (
<UserProfilePopup
user={msgUser}
anchorEl={avatarRef.current}
onClose={() => setShowProfile(false)}
onDirectMessage={onDirectMessage}
/>
)}
{lightboxSrc && (
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
)}
</>
);
}
function LinkPreview({ data: raw }) {
let d;
try { d = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; }
if (!d?.title) return null;
return (
<a href={d.url} target="_blank" rel="noopener noreferrer" className="link-preview">
{d.image && <img src={d.image} alt="" className="link-preview-img" onError={e => e.target.style.display = 'none'} />}
<div className="link-preview-content">
{d.siteName && <span className="link-site">{d.siteName}</span>}
<span className="link-title">{d.title}</span>
{d.description && <span className="link-desc">{d.description}</span>}
</div>
</a>
);
}
function formatTime(dateStr) {
return parseTS(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatDate(dateStr) {
const d = parseTS(dateStr);
const now = new Date();
if (d.toDateString() === now.toDateString()) return 'Today';
const yest = new Date(now); yest.setDate(yest.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return 'Yesterday';
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
}

View File

@@ -0,0 +1,249 @@
.message-input-area {
background: white;
border-top: 1px solid var(--border);
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0; /* never compress — always visible above keyboard */
position: relative;
z-index: 2;
/* Centre input content with max-width on wide screens */
align-items: stretch;
}
/* All direct children of the input area capped at 1024px and centred */
.message-input-area > * {
max-width: 1024px;
width: 100%;
align-self: center;
box-sizing: border-box;
}
.reply-bar-input {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--primary-light);
border-radius: var(--radius);
border-left: 3px solid var(--primary);
}
.reply-indicator {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
overflow: hidden;
font-size: 13px;
color: var(--primary);
}
.reply-preview-text {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.img-preview-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background: var(--background);
border-radius: var(--radius);
}
.img-preview {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: var(--radius);
}
.link-preview-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--background);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.link-prev-img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.mention-dropdown {
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
max-height: 200px;
overflow-y: auto;
}
.mention-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
width: 100%;
font-size: 14px;
transition: var(--transition);
cursor: pointer;
}
.mention-item:hover, .mention-item.active { background: var(--primary-light); }
.mention-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary);
color: white;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mention-role {
margin-left: auto;
font-size: 11px;
color: var(--text-tertiary);
text-transform: capitalize;
}
.input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.input-action {
color: var(--text-secondary);
flex-shrink: 0;
margin-bottom: 2px;
}
.input-action:hover { color: var(--primary); }
.input-wrap {
flex: 1;
min-width: 0;
}
.msg-input {
width: 100%;
min-height: 40px;
max-height: calc(1.4em * 5 + 20px); /* 5 lines × line-height + padding */
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 20px;
font-size: calc(0.875rem * var(--font-scale));
line-height: 1.4;
font-family: var(--font);
color: var(--text-primary);
background: var(--surface-variant);
transition: border-color var(--transition);
overflow-y: hidden;
resize: none;
}
.msg-input:focus { outline: none; border-color: var(--primary); background: var(--surface-variant); }
.msg-input::placeholder { color: var(--text-tertiary); }
.send-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-tertiary);
transition: var(--transition);
background: var(--background);
}
.send-btn.active {
background: var(--primary);
color: white;
}
.send-btn.active:hover { background: var(--primary-dark); }
.send-btn:disabled { opacity: 0.4; cursor: default; }
/* + attach button */
.attach-wrap {
position: relative;
flex-shrink: 0;
}
.attach-btn {
color: var(--primary);
}
.attach-btn:hover {
color: var(--primary-dark);
}
/* Attach menu popup */
.attach-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
z-index: 100;
min-width: 140px;
}
.attach-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
width: 100%;
font-size: 14px;
color: var(--text-primary);
transition: var(--transition);
white-space: nowrap;
}
.attach-item:hover {
background: var(--primary-light);
color: var(--primary);
}
.attach-item svg {
flex-shrink: 0;
color: var(--text-secondary);
}
.attach-item:hover svg {
color: var(--primary);
}
/* Emoji picker popover — positioned above the input area */
.emoji-input-picker {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
z-index: 200;
}
/* PC only: enforce minimum width on the input row so send button never disappears */
@media (pointer: fine) and (hover: hover) {
.input-row {
min-width: 480px;
}
}

View File

@@ -0,0 +1,388 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { api } from '../utils/api.js';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import './MessageInput.css';
const URL_REGEX = /https?:\/\/[^\s]+/g;
// Detect if a string is purely emoji characters (no other text)
function isEmojiOnly(str) {
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u;
return emojiRegex.test(str.trim());
}
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onlineUserIds = new Set() }) {
const [text, setText] = useState('');
const [imageFile, setImageFile] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const [mentionSearch, setMentionSearch] = useState('');
const [mentionResults, setMentionResults] = useState([]);
const [mentionIndex, setMentionIndex] = useState(-1);
const [showMention, setShowMention] = useState(false);
const [linkPreview, setLinkPreview] = useState(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const inputRef = useRef(null);
const typingTimer = useRef(null);
const wasTyping = useRef(false);
const mentionStart = useRef(-1);
const fileInput = useRef(null);
const cameraInput = useRef(null);
const attachMenuRef = useRef(null);
const emojiPickerRef = useRef(null);
// Close attach menu / emoji picker on outside click
useEffect(() => {
const handler = (e) => {
if (attachMenuRef.current && !attachMenuRef.current.contains(e.target)) {
setShowAttachMenu(false);
}
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target)) {
setShowEmojiPicker(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// Handle typing notification
const handleTypingChange = (value) => {
if (value && !wasTyping.current) {
wasTyping.current = true;
onTyping(true);
}
if (typingTimer.current) clearTimeout(typingTimer.current);
typingTimer.current = setTimeout(() => {
if (wasTyping.current) {
wasTyping.current = false;
onTyping(false);
}
}, 2000);
};
// Link preview — 5 second timeout, then abandon and enable Send
const previewTimeoutRef = useRef(null);
const fetchPreview = useCallback(async (url) => {
setLoadingPreview(true);
setLinkPreview(null);
if (previewTimeoutRef.current) clearTimeout(previewTimeoutRef.current);
const abandonTimer = setTimeout(() => {
setLoadingPreview(false);
}, 5000);
previewTimeoutRef.current = abandonTimer;
try {
const { preview } = await api.getLinkPreview(url);
clearTimeout(abandonTimer);
if (preview) setLinkPreview(preview);
} catch {
clearTimeout(abandonTimer);
}
setLoadingPreview(false);
}, []);
const handleChange = (e) => {
const val = e.target.value;
setText(val);
handleTypingChange(val);
const el = e.target;
el.style.height = 'auto';
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
const maxHeight = lineHeight * 5 + 20;
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
const cur = e.target.selectionStart;
const lastAt = val.lastIndexOf('@', cur - 1);
if (lastAt !== -1) {
const between = val.slice(lastAt + 1, cur);
if (!between.includes(' ') && !between.includes('\n')) {
mentionStart.current = lastAt;
setMentionSearch(between);
setShowMention(true);
api.searchUsers(between, group?.id).then(({ users }) => {
setMentionResults(users);
setMentionIndex(0);
}).catch(() => {});
return;
}
}
setShowMention(false);
const urls = val.match(URL_REGEX);
if (urls && urls[0] !== linkPreview?.url) {
fetchPreview(urls[0]);
} else if (!urls) {
setLinkPreview(null);
}
};
const insertMention = (user) => {
const before = text.slice(0, mentionStart.current);
const after = text.slice(inputRef.current.selectionStart);
const name = user.display_name || user.name;
setText(before + `@[${name}] ` + after);
setShowMention(false);
setMentionResults([]);
inputRef.current.focus();
};
const handleKeyDown = (e) => {
if (showMention && mentionResults.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => Math.min(i + 1, mentionResults.length - 1)); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => Math.max(i - 1, 0)); return; }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (mentionIndex >= 0) insertMention(mentionResults[mentionIndex]); return; }
if (e.key === 'Escape') { setShowMention(false); return; }
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleSend = async () => {
const trimmed = text.trim();
if (!trimmed && !imageFile) return;
const lp = linkPreview;
setText('');
setLinkPreview(null);
setImageFile(null);
setImagePreview(null);
wasTyping.current = false;
onTyping(false);
if (inputRef.current) {
inputRef.current.style.height = 'auto';
inputRef.current.style.overflowY = 'hidden';
}
const emojiOnly = !!trimmed && isEmojiOnly(trimmed);
await onSend({ content: trimmed || null, imageFile, linkPreview: lp, emojiOnly });
};
// Insert emoji at cursor position in the textarea
const handleEmojiSelect = (emoji) => {
setShowEmojiPicker(false);
const el = inputRef.current;
const native = emoji.native;
if (el) {
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const newText = text.slice(0, start) + native + text.slice(end);
setText(newText);
// Restore focus and move cursor after the inserted emoji
requestAnimationFrame(() => {
el.focus();
const pos = start + native.length;
el.setSelectionRange(pos, pos);
// Resize textarea
el.style.height = 'auto';
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
const maxHeight = lineHeight * 5 + 20;
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
});
} else {
// No ref yet — just append
setText(prev => prev + native);
}
};
const compressImage = (file) => new Promise((resolve) => {
const MAX_PX = 1920;
const QUALITY = 0.82;
const isPng = file.type === 'image/png';
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let { width, height } = img;
if (width <= MAX_PX && height <= MAX_PX) {
// already small
} else {
const ratio = Math.min(MAX_PX / width, MAX_PX / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!isPng) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(img, 0, 0, width, height);
if (isPng) {
canvas.toBlob(blob => resolve(new File([blob], file.name, { type: 'image/png' })), 'image/png');
} else {
canvas.toBlob(blob => resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' })), 'image/jpeg', QUALITY);
}
};
img.src = url;
});
const handleImageSelect = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const compressed = await compressImage(file);
setImageFile(compressed);
const reader = new FileReader();
reader.onload = (e) => setImagePreview(e.target.result);
reader.readAsDataURL(compressed);
setShowAttachMenu(false);
};
// Detect mobile (touch device)
const isMobile = () => window.matchMedia('(pointer: coarse)').matches;
return (
<div className="message-input-area">
{/* Reply preview */}
{replyTo && (
<div className="reply-bar-input">
<div className="reply-indicator">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
<span>Replying to <strong>{replyTo.user_display_name || replyTo.user_name}</strong></span>
<span className="reply-preview-text">{replyTo.content?.slice(0, 60) || (replyTo.image_url ? '📷 Image' : '')}</span>
</div>
<button className="btn-icon" onClick={onCancelReply}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
)}
{/* Image preview */}
{imagePreview && (
<div className="img-preview-bar">
<img src={imagePreview} alt="preview" className="img-preview" />
<button className="btn-icon" onClick={() => { setImageFile(null); setImagePreview(null); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
)}
{/* Link preview */}
{linkPreview && (
<div className="link-preview-bar">
{linkPreview.image && <img src={linkPreview.image} alt="" className="link-prev-img" onError={e => e.target.style.display='none'} />}
<div className="flex-col flex-1 overflow-hidden gap-1">
{linkPreview.siteName && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', textTransform: 'uppercase' }}>{linkPreview.siteName}</span>}
<span style={{ fontSize: 13, fontWeight: 600 }} className="truncate">{linkPreview.title}</span>
</div>
<button className="btn-icon" onClick={() => setLinkPreview(null)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
)}
{/* Mention dropdown */}
{showMention && mentionResults.length > 0 && (
<div className="mention-dropdown">
{mentionResults.map((u, i) => (
<button
key={u.id}
className={`mention-item ${i === mentionIndex ? 'active' : ''}`}
onMouseDown={(e) => { e.preventDefault(); insertMention(u); }}
>
<div className="mention-avatar-wrap">
<div className="mention-avatar">{(u.display_name || u.name)?.[0]?.toUpperCase()}</div>
{onlineUserIds.has(u.id) && <span className="mention-online-dot" />}
</div>
<span>{u.display_name || u.name}</span>
<span className="mention-role">{u.role}</span>
</button>
))}
</div>
)}
<div className="input-row">
{/* + button — attach menu trigger */}
<div className="attach-wrap" ref={attachMenuRef}>
<button
className="btn-icon input-action attach-btn"
onClick={() => { setShowAttachMenu(v => !v); setShowEmojiPicker(false); }}
title="Add photo or emoji"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="22" height="22">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
{showAttachMenu && (
<div className="attach-menu">
{/* Photo from library */}
<button className="attach-item" onClick={() => fileInput.current?.click()}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span>Photo</span>
</button>
{/* Camera — mobile only */}
{isMobile() && (
<button className="attach-item" onClick={() => cameraInput.current?.click()}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
<span>Camera</span>
</button>
)}
{/* Emoji */}
<button className="attach-item" onClick={() => { setShowAttachMenu(false); setShowEmojiPicker(true); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 13s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
<span>Emoji</span>
</button>
</div>
)}
</div>
{/* Hidden file inputs */}
<input ref={fileInput} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageSelect} />
<input ref={cameraInput} type="file" accept="image/*" capture="environment" style={{ display: 'none' }} onChange={handleImageSelect} />
{/* Emoji picker popover */}
{showEmojiPicker && (
<div className="emoji-input-picker" ref={emojiPickerRef}>
<Picker
data={data}
onEmojiSelect={handleEmojiSelect}
theme="light"
previewPosition="none"
skinTonePosition="none"
maxFrequentRows={2}
/>
</div>
)}
<div className="input-wrap">
<textarea
ref={inputRef}
className="msg-input"
placeholder={`Message ${group?.name || ''}...`}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
rows={1}
style={{ resize: 'none' }}
/>
</div>
<button
className={`send-btn ${(text.trim() || imageFile) && !loadingPreview ? 'active' : ''}`}
onClick={handleSend}
disabled={(!text.trim() && !imageFile) || loadingPreview}
title={loadingPreview ? 'Loading preview…' : 'Send'}
>
{loadingPreview
? <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import Avatar from './Avatar.jsx';
export default function NewChatModal({ onClose, onCreated }) {
const { user } = useAuth();
const toast = useToast();
const [tab, setTab] = useState('private'); // 'private' | 'public'
const [name, setName] = useState('');
const [isReadonly, setIsReadonly] = useState(false);
const [search, setSearch] = useState('');
const [users, setUsers] = useState([]);
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
// True when exactly 1 user selected on private tab = direct message
const isDirect = tab === 'private' && selected.length === 1;
useEffect(() => {
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
}, []);
useEffect(() => {
if (search) {
api.searchUsers(search).then(({ users }) => setUsers(users)).catch(() => {});
}
}, [search]);
const toggle = (u) => {
if (u.id === user.id) return;
setSelected(prev => prev.find(p => p.id === u.id) ? prev.filter(p => p.id !== u.id) : [...prev, u]);
};
const handleCreate = async () => {
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
if (tab === 'private' && selected.length > 1 && !name.trim()) return toast('Name required', 'error');
if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
setLoading(true);
try {
let payload;
if (isDirect) {
// Direct message: no name, isDirect flag
payload = {
type: 'private',
memberIds: selected.map(u => u.id),
isDirect: true,
};
} else {
payload = {
name: name.trim(),
type: tab,
memberIds: selected.map(u => u.id),
isReadonly: tab === 'public' && isReadonly,
};
}
const { group, duplicate } = await api.createGroup(payload);
if (duplicate) {
toast('A group with these members already exists — opening it now.', 'info');
} else {
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
}
onCreated(group);
} catch (e) {
toast(e.message, 'error');
} finally {
setLoading(false);
}
};
// Placeholder for the name field
const namePlaceholder = isDirect
? selected[0]?.name || ''
: tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team';
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal">
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Start a Chat</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{user.role === 'admin' && (
<div className="flex gap-2" style={{ marginBottom: 20 }}>
<button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Direct Message</button>
<button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Message</button>
</div>
)}
{/* Message Name — only shown when needed: public always, private only when 2+ members selected */}
{(tab === 'public' || (tab === 'private' && selected.length > 1)) && (
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label>
<input
className="input"
value={name}
onChange={e => setName(e.target.value)}
placeholder={namePlaceholder}
/>
</div>
)}
{/* Readonly toggle for public */}
{tab === 'public' && user.role === 'admin' && (
<label className="flex items-center gap-2 text-sm" style={{ marginBottom: 16, cursor: 'pointer', color: 'var(--text-secondary)' }}>
<input type="checkbox" checked={isReadonly} onChange={e => setIsReadonly(e.target.checked)} />
Read-only message (only admins can post)
</label>
)}
{/* Member selector for private tab */}
{tab === 'private' && (
<>
<div className="flex-col gap-2" style={{ marginBottom: 12 }}>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{isDirect ? 'Direct Message with' : 'Add Members'}
</label>
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
</div>
{selected.length > 0 && (
<div className="flex gap-2" style={{ flexWrap: 'wrap', marginBottom: 12 }}>
{selected.map(u => (
<span key={u.id} className="chip">
{u.name}
<span className="chip-remove" onClick={() => toggle(u)}>×</span>
</span>
))}
</div>
)}
{isDirect && (
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 12, fontStyle: 'italic' }}>
A private two-person conversation. Select a second person to create a group instead.
</p>
)}
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
{users.filter(u => u.id !== user.id && u.allow_dm !== 0).map(u => (
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
<Avatar user={u} size="sm" />
<span className="flex-1 text-sm">{u.name}</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span>
</label>
))}
</div>
</>
)}
<div className="flex gap-2 justify-between" style={{ marginTop: 20 }}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={handleCreate} disabled={loading}>
{loading ? 'Creating...' : isDirect ? 'Start Conversation' : 'Create'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
import Avatar from './Avatar.jsx';
export default function ProfileModal({ onClose }) {
const { user, updateUser } = useAuth();
const toast = useToast();
const [displayName, setDisplayName] = useState(user?.display_name || '');
const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
const [displayNameWarning, setDisplayNameWarning] = useState('');
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
const [currentPw, setCurrentPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState('profile'); // 'profile' | 'password'
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
const handleSaveProfile = async () => {
if (displayNameWarning) return toast('Display name is already in use', 'error');
setLoading(true);
try {
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm });
updateUser(updated);
setSavedDisplayName(displayName);
toast('Profile updated', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
setLoading(false);
}
};
const handleAvatarUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const { avatarUrl } = await api.uploadAvatar(file);
updateUser({ avatar: avatarUrl });
toast('Avatar updated', 'success');
} catch (e) {
toast(e.message, 'error');
}
};
const handleChangePassword = async () => {
if (newPw !== confirmPw) return toast('Passwords do not match', 'error');
if (newPw.length < 8) return toast('Password too short (min 8)', 'error');
setLoading(true);
try {
await api.changePassword({ currentPassword: currentPw, newPassword: newPw });
toast('Password changed', 'success');
setCurrentPw(''); setNewPw(''); setConfirmPw('');
} catch (e) {
toast(e.message, 'error');
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal">
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>My Profile</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* Avatar */}
<div className="flex items-center gap-3" style={{ gap: 16, marginBottom: 20 }}>
<div style={{ position: 'relative' }}>
<Avatar user={user} size="xl" />
<label title="Change avatar" style={{
position: 'absolute', bottom: 0, right: 0,
background: 'var(--primary)', color: 'white', borderRadius: '50%',
width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 12
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarUpload} />
</label>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 16 }}>{user?.display_name || user?.name}</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>{user?.email}</div>
<span className={`role-badge role-${user?.role}`}>{user?.role}</span>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2" style={{ marginBottom: 20 }}>
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button>
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button>
</div>
{tab === 'profile' && (
<div className="flex-col gap-3">
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Display Name</label>
<div className="flex gap-2">
<input
className="input flex-1"
value={displayName}
onChange={async e => {
const val = e.target.value;
setDisplayName(val);
setDisplayNameWarning('');
if (val && val !== user?.display_name) {
try {
const { taken } = await api.checkDisplayName(val);
if (taken) setDisplayNameWarning('Display name is already in use');
} catch {}
}
}}
placeholder={user?.name}
style={{ borderColor: displayNameWarning ? '#e53935' : undefined }}
/>
{displayName !== savedDisplayName ? null : savedDisplayName ? (
<button
className="btn btn-sm"
style={{ background: 'var(--surface-variant)', color: 'var(--text-secondary)', flexShrink: 0 }}
onClick={() => setDisplayName('')}
type="button"
>
Remove
</button>
) : null}
</div>
{displayNameWarning && <span className="text-xs" style={{ color: '#e53935' }}>{displayNameWarning}</span>}
{savedDisplayName && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Username: {user?.name}</span>}
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label>
<textarea className="input" value={aboutMe} onChange={e => setAboutMe(e.target.value)} placeholder="Tell your team about yourself..." rows={3} style={{ resize: 'vertical' }} />
</div>
{user?.role === 'admin' && (
<label className="flex items-center gap-2 text-sm pointer" style={{ color: 'var(--text-secondary)', userSelect: 'none' }}>
<input
type="checkbox"
checked={hideAdminTag}
onChange={e => setHideAdminTag(e.target.checked)}
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }}
/>
Hide "Admin" tag next to my name in messages
</label>
)}
<label className="flex items-center gap-2 text-sm pointer" style={{ color: 'var(--text-secondary)', userSelect: 'none' }}>
<input
type="checkbox"
checked={allowDm}
onChange={e => setAllowDm(e.target.checked)}
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }}
/>
Allow others to send me direct messages
</label>
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</div>
)}
{tab === 'password' && (
<div className="flex-col gap-3">
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label>
<input className="input" type="password" value={currentPw} onChange={e => setCurrentPw(e.target.value)} />
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label>
<input className="input" type="password" value={newPw} onChange={e => setNewPw(e.target.value)} />
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Confirm New Password</label>
<input className="input" type="password" value={confirmPw} onChange={e => setConfirmPw(e.target.value)} />
</div>
<button className="btn btn-primary" onClick={handleChangePassword} disabled={loading || !currentPw || !newPw}>
{loading ? 'Changing...' : 'Change Password'}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { useState, useEffect } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
export default function SettingsModal({ onClose }) {
const toast = useToast();
const [vapidPublic, setVapidPublic] = useState('');
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [showRegenWarning, setShowRegenWarning] = useState(false);
useEffect(() => {
api.getSettings().then(({ settings }) => {
setVapidPublic(settings.vapid_public || '');
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const doGenerate = async () => {
setGenerating(true);
setShowRegenWarning(false);
try {
const { publicKey } = await api.generateVapidKeys();
setVapidPublic(publicKey);
toast('VAPID keys generated. Push notifications are now active.', 'success');
} catch (e) {
toast(e.message || 'Failed to generate keys', 'error');
} finally {
setGenerating(false);
}
};
const handleGenerateClick = () => {
if (vapidPublic) {
setShowRegenWarning(true);
} else {
doGenerate();
}
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 480 }}>
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Settings</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div>
<div className="settings-section-label">Web Push Notifications (VAPID)</div>
{loading ? (
<p style={{ fontSize: 13, color: "var(--text-secondary)" }}>Loading</p>
) : (
<>
{vapidPublic ? (
<div style={{ marginBottom: 16 }}>
<div style={{
background: "var(--surface-variant)",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
padding: "10px 12px",
marginBottom: 10,
}}>
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.5px" }}>
Public Key
</div>
<code style={{
fontSize: 11,
color: "var(--text-primary)",
wordBreak: "break-all",
lineHeight: 1.5,
display: "block",
}}>
{vapidPublic}
</code>
</div>
<span style={{ fontSize: 13, color: "var(--success)", display: "flex", alignItems: "center", gap: 5 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Push notifications active
</span>
</div>
) : (
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 12 }}>
No VAPID keys found. Generate keys to enable Web Push notifications users can then receive alerts when the app is closed or in the background.
</p>
)}
{showRegenWarning && (
<div style={{
background: "#fce8e6",
border: "1px solid #f5c6c2",
borderRadius: "var(--radius)",
padding: "14px 16px",
marginBottom: 16,
}}>
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--error)", marginBottom: 8 }}>
Regenerate VAPID keys?
</p>
<p style={{ fontSize: 13, color: "#5c2c28", marginBottom: 12, lineHeight: 1.5 }}>
Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Every user will stop receiving push notifications immediately and will need to re-enable them by opening the app. This cannot be undone.
</p>
<div style={{ display: "flex", gap: 8 }}>
<button
className="btn btn-sm"
style={{ background: "var(--error)", color: "white" }}
onClick={doGenerate}
disabled={generating}
>
{generating ? "Generating…" : "Yes, regenerate keys"}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>
Cancel
</button>
</div>
</div>
)}
{!showRegenWarning && (
<button className="btn btn-primary btn-sm" onClick={handleGenerateClick} disabled={generating}>
{generating ? "Generating…" : vapidPublic ? "Regenerate Keys" : "Generate Keys"}
</button>
)}
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 12, lineHeight: 1.5 }}>
Requires HTTPS. After generating, users will be prompted to enable notifications on their next visit. On iOS, the app must be installed to the home screen first.
</p>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,283 @@
.sidebar {
width: 320px;
min-width: 320px;
height: 100%;
min-height: 0;
background: white;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
@media (max-width: 767px) {
.sidebar { width: 100vw; min-width: 0; }
}
.sidebar-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-title {
font-size: 20px;
font-weight: 700;
color: var(--primary);
flex: 1;
}
.sidebar-logo {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: contain;
flex-shrink: 0;
}
.offline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #e53935;
flex-shrink: 0;
}
/* New Chat bar (desktop) */
.sidebar-newchat-bar {
padding: 10px 12px 6px;
flex-shrink: 0;
}
.newchat-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 9px 16px;
border-radius: 20px;
background: var(--primary-light);
color: var(--primary);
font-size: 14px;
font-weight: 600;
border: 1.5px solid var(--primary);
cursor: pointer;
transition: background var(--transition), color var(--transition);
}
.newchat-btn:hover { background: var(--primary); color: white; }
.newchat-btn:hover svg { stroke: white; }
/* Mobile FAB */
.newchat-fab {
position: absolute;
bottom: 80px;
right: 16px;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
z-index: 10;
cursor: pointer;
transition: background var(--transition), transform 80ms ease;
border: none;
pointer-events: auto;
}
.newchat-fab:hover { transform: scale(1.05); }
.newchat-fab:active { transform: scale(0.97); }
.groups-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.group-section { margin-bottom: 8px; }
.section-label {
padding: 8px 16px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
letter-spacing: 0.8px;
}
.group-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
cursor: pointer;
transition: background var(--transition);
}
.group-item:hover { background: var(--background); }
.group-item.active { background: var(--primary-light); }
.group-item.active .group-name { color: var(--primary); font-weight: 600; }
.group-icon {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.group-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.group-name {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.group-time {
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.group-last-msg {
font-size: 13px;
color: var(--text-secondary);
}
.sidebar-footer {
border-top: 1px solid var(--border);
padding: 12px 8px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
position: relative;
flex-shrink: 0;
}
.user-footer-btn {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px;
border-radius: var(--radius);
transition: background var(--transition);
color: var(--text-primary);
}
.user-footer-btn:hover { background: var(--background); }
.footer-menu {
position: absolute;
bottom: 68px;
left: 8px;
right: 8px;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
padding: 8px;
z-index: 100;
animation: slideUp 150ms ease;
}
.footer-menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border-radius: var(--radius);
font-size: 14px;
color: var(--text-primary);
transition: background var(--transition);
}
.footer-menu-item:hover { background: var(--background); }
.footer-menu-item.danger { color: var(--error); }
.footer-menu-item.danger:hover { background: #fce8e6; }
.group-item.has-unread { background: var(--surface-variant); }
.unread-name { font-weight: 700; color: var(--text-primary) !important; }
.badge-unread {
background: #e53935;
color: white;
font-size: 11px;
font-weight: 600;
min-width: 18px;
height: 18px;
border-radius: 9px;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: center;
}
/* DM real name in brackets — smaller, muted */
.dm-real-name {
font-size: 11px;
font-weight: 400;
color: var(--text-tertiary);
}
/* Online presence dot on DM avatars */
.group-icon-wrap {
position: relative;
flex-shrink: 0;
display: inline-flex;
}
.online-dot {
position: absolute;
bottom: 1px;
right: 1px;
width: 11px;
height: 11px;
background: #34a853;
border-radius: 50%;
border: 2px solid var(--surface);
pointer-events: none;
}
/* DM right-click context menu */
.dm-context-menu {
position: fixed;
z-index: 9999;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: 4px 0;
min-width: 180px;
}
.dm-context-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
font-size: 13px;
color: var(--text-primary);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background var(--transition);
}
.dm-context-menu button:hover {
background: var(--surface-variant);
}

View File

@@ -0,0 +1,263 @@
import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useSocket } from '../contexts/SocketContext.jsx';
import { api, parseTS } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import Avatar from './Avatar.jsx';
import './Sidebar.css';
function useTheme() {
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
}, [dark]);
return [dark, setDark];
}
function useAppSettings() {
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
const fetchSettings = () => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
};
useEffect(() => {
fetchSettings();
window.addEventListener('jama:settings-changed', fetchSettings);
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
}, []);
useEffect(() => {
const name = settings.app_name || 'jama';
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
document.title = prefix + name;
const faviconUrl = settings.logo_url || '/icons/jama.png';
let link = document.querySelector("link[rel~='icon']");
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
link.href = faviconUrl;
}, [settings]);
return settings;
}
function formatTime(dateStr) {
if (!dateStr) return '';
const date = parseTS(dateStr);
const now = new Date();
const diff = now - date;
if (diff < 86400000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
if (diff < 604800000) {
return date.toLocaleDateString([], { weekday: 'short' });
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set() }) {
const { user, logout } = useAuth();
const { connected } = useSocket();
const toast = useToast();
const [showMenu, setShowMenu] = useState(false);
const settings = useAppSettings();
const [dark, setDark] = useTheme();
const menuRef = useRef(null);
const footerBtnRef = useRef(null);
useEffect(() => {
if (!showMenu) return;
const handler = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target) &&
footerBtnRef.current && !footerBtnRef.current.contains(e.target)) {
setShowMenu(false);
}
};
document.addEventListener('mousedown', handler);
document.addEventListener('touchstart', handler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('touchstart', handler);
};
}, [showMenu]);
const allGroups = [
...(groups.publicGroups || []),
...(groups.privateGroups || [])
];
const publicFiltered = allGroups.filter(g => g.type === 'public');
const privateFiltered = [...allGroups.filter(g => g.type === 'private')].sort((a, b) => {
if (!a.last_message_at && !b.last_message_at) return 0;
if (!a.last_message_at) return 1;
if (!b.last_message_at) return -1;
return new Date(b.last_message_at) - new Date(a.last_message_at);
});
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
const handleLogout = async () => { await logout(); };
const GroupItem = ({ group }) => {
const notifs = getNotifCount(group.id);
const unreadCount = unreadGroups.get(group.id) || 0;
const hasUnread = unreadCount > 0;
const isActive = group.id === activeGroupId;
const isOnline = !!group.is_direct && !!group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false);
return (
<div
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
onClick={() => onSelectGroup(group.id)}
>
<div className="group-icon-wrap">
{group.is_direct && group.peer_avatar ? (
<img src={group.peer_avatar} alt={group.name} className="group-icon" style={{ objectFit: 'cover', padding: 0 }} />
) : (
<div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#a142f4') }}>
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
</div>
)}
{isOnline && <span className="online-dot" />}
</div>
<div className="group-info flex-1 overflow-hidden">
<div className="flex items-center justify-between">
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>
{group.is_direct && group.peer_display_name
? <>{group.peer_display_name}<span className="dm-real-name"> ({group.peer_real_name})</span></>
: group.is_direct && group.peer_real_name ? group.peer_real_name : group.name}
</span>
{group.last_message_at && (
<span className="group-time">{formatTime(group.last_message_at)}</span>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="group-last-msg truncate">
{(() => {
const preview = (group.last_message || '').replace(/@\[([^\]]+)\]/g, '@$1');
if (!preview) return group.is_readonly ? '📢 Read-only' : 'No messages yet';
const isOwn = group.last_message_user_id && user && group.last_message_user_id === user.id;
return isOwn ? <><strong style={{ fontWeight: 600 }}>You:</strong> {preview}</> : preview;
})()}
</span>
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
{hasUnread && notifs === 0 && <span className="badge badge-unread shrink-0">{unreadCount}</span>}
</div>
</div>
</div>
);
};
return (
<div className="sidebar">
<div className="sidebar-newchat-bar">
{!isMobile && (
<button className="newchat-btn" onClick={onNewChat}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="18" height="18">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
New Chat
</button>
)}
</div>
<div className="groups-list">
{publicFiltered.length > 0 && (
<div className="group-section">
<div className="section-label">PUBLIC MESSAGES</div>
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
</div>
)}
{privateFiltered.length > 0 && (
<div className="group-section">
<div className="section-label">MESSAGES</div>
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
</div>
)}
{allGroups.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
No chats yet
</div>
)}
</div>
{isMobile && (
<button className="newchat-fab" onClick={onNewChat} title="New Chat">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
</button>
)}
<div className="sidebar-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button ref={footerBtnRef} className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
<Avatar user={user} size="sm" />
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</svg>
</button>
<button
className="btn-icon"
onClick={() => setDark(d => !d)}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
style={{ flexShrink: 0, padding: 8 }}
>
{dark ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
)}
</button>
</div>
{showMenu && (
<div ref={menuRef} className="footer-menu">
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onProfile(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profile
</button>
{user?.role === 'admin' && (
<>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onUsers(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
User Manager
</button>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onBranding && onBranding(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 6V2M12 22v-4M6 12H2M22 12h-4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
Branding
</button>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onOpenSettings(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
Settings
</button>
</>
)}
<hr className="divider" style={{ margin: '4px 0' }} />
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onHelp && onHelp(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Help
</button>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onAbout && onAbout(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
About
</button>
<hr className="divider" style={{ margin: '4px 0' }} />
<button className="footer-menu-item danger" onClick={handleLogout}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Sign out
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { useState, useEffect } from 'react';
import { api } from '../utils/api.js';
function generateCaptcha() {
const a = Math.floor(Math.random() * 9) + 1;
const b = Math.floor(Math.random() * 9) + 1;
const ops = [
{ label: `${a} + ${b}`, answer: a + b },
{ label: `${a + b} - ${b}`, answer: a },
{ label: `${a} × ${b}`, answer: a * b },
];
return ops[Math.floor(Math.random() * ops.length)];
}
export default function SupportModal({ onClose }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [captcha, setCaptcha] = useState(generateCaptcha);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const refreshCaptcha = () => {
setCaptcha(generateCaptcha());
setCaptchaAnswer('');
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!name.trim() || !email.trim() || !message.trim()) {
return setError('Please fill in all fields.');
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return setError('Please enter a valid email address.');
}
if (parseInt(captchaAnswer, 10) !== captcha.answer) {
setError('Incorrect answer — please try again.');
refreshCaptcha();
return;
}
setLoading(true);
try {
await api.submitSupport({ name, email, message });
setSent(true);
} catch (err) {
setError(err.message || 'Failed to send. Please try again.');
refreshCaptcha();
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 440 }}>
{sent ? (
/* Success state */
<div style={{ textAlign: 'center', padding: '8px 0 16px' }}>
<div style={{
width: 56, height: 56, borderRadius: '50%',
background: '#e6f4ea', display: 'flex', alignItems: 'center',
justifyContent: 'center', margin: '0 auto 16px'
}}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34a853" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<h3 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Message Sent</h3>
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 24, lineHeight: 1.5 }}>
Your message has been received. An administrator will follow up with you shortly.
</p>
<button className="btn btn-primary" onClick={onClose} style={{ minWidth: 120 }}>
Close
</button>
</div>
) : (
/* Form state */
<>
<div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Contact Support</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 20 }}>
Fill out the form below and an administrator will get back to you.
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Your Name</label>
<input
className="input"
placeholder="Jane Smith"
value={name}
onChange={e => setName(e.target.value)}
maxLength={100}
/>
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Your Email</label>
<input
className="input"
type="email"
placeholder="jane@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
maxLength={200}
/>
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message</label>
<textarea
className="input"
placeholder="Describe your issue or question..."
value={message}
onChange={e => setMessage(e.target.value)}
rows={4}
maxLength={2000}
style={{ resize: 'vertical' }}
/>
<span className="text-xs" style={{ color: 'var(--text-tertiary)', alignSelf: 'flex-end' }}>
{message.length}/2000
</span>
</div>
{/* Math captcha */}
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
Security Check
</label>
<div className="flex items-center gap-2" style={{ gap: 10 }}>
<div style={{
background: 'var(--background)', border: '1px solid var(--border)',
borderRadius: 'var(--radius)', padding: '9px 16px',
fontSize: 15, fontWeight: 700, letterSpacing: 2,
color: 'var(--text-primary)', fontFamily: 'monospace',
flexShrink: 0, userSelect: 'none'
}}>
{captcha.label} = ?
</div>
<input
className="input"
type="number"
placeholder="Answer"
value={captchaAnswer}
onChange={e => setCaptchaAnswer(e.target.value)}
style={{ width: 90 }}
min={0}
max={999}
/>
<button
type="button"
className="btn-icon"
onClick={refreshCaptcha}
title="New question"
style={{ color: 'var(--text-secondary)', flexShrink: 0 }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
</div>
</div>
{error && (
<div style={{
background: '#fce8e6', border: '1px solid #f5c6c2',
borderRadius: 'var(--radius)', padding: '10px 14px',
fontSize: 13, color: 'var(--error)'
}}>
{error}
</div>
)}
<button className="btn btn-primary" type="submit" disabled={loading} style={{ marginTop: 4 }}>
{loading
? <><span className="spinner" style={{ width: 16, height: 16 }} /> Sending...</>
: 'Send Message'
}
</button>
</form>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,415 @@
import { useState, useEffect, useRef } from 'react';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
import Avatar from './Avatar.jsx';
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function parseCSV(text) {
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const rows = [], invalid = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (i === 0 && /^name\s*,/i.test(line)) continue;
const parts = line.split(',').map(p => p.trim());
if (parts.length < 2 || parts.length > 4) { invalid.push({ line, reason: 'Must have 24 comma-separated fields' }); continue; }
const [name, email, password, role] = parts;
if (!name || !/\S+\s+\S+/.test(name)) { invalid.push({ line, reason: 'Name must be two words (First Last)' }); continue; }
if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email}"` }); continue; }
rows.push({ name: name.trim(), email: email.trim().toLowerCase(), password: (password || '').trim(), role: (role || 'member').trim().toLowerCase() });
}
return { rows, invalid };
}
function UserRow({ u, onUpdated }) {
const toast = useToast();
const [open, setOpen] = useState(false);
const [resetPw, setResetPw] = useState('');
const [showReset, setShowReset] = useState(false);
const [editName, setEditName] = useState(false);
const [nameVal, setNameVal] = useState(u.name);
const [roleWarning, setRoleWarning] = useState(false);
const handleRole = async (role) => {
if (!role) { setRoleWarning(true); return; }
setRoleWarning(false);
try { await api.updateRole(u.id, role); toast('Role updated', 'success'); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
const handleResetPw = async () => {
if (!resetPw || resetPw.length < 6) return toast('Min 6 characters', 'error');
try { await api.resetPassword(u.id, resetPw); toast('Password reset', 'success'); setShowReset(false); setResetPw(''); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
const handleSaveName = async () => {
if (!nameVal.trim()) return toast('Name cannot be empty', 'error');
try {
const { name } = await api.updateName(u.id, nameVal.trim());
toast(name !== nameVal.trim() ? `Saved as "${name}"` : 'Name updated', 'success');
setEditName(false); onUpdated();
} catch (e) { toast(e.message, 'error'); }
};
const handleSuspend = async () => {
if (!confirm(`Suspend ${u.name}?`)) return;
try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
const handleActivate = async () => {
try { await api.activateUser(u.id); toast('User activated', 'success'); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
const handleDelete = async () => {
if (u.role === 'admin') return toast('Demote to member before deleting an admin', 'error');
if (!confirm(`Delete ${u.name}? Their messages will remain but they cannot log in.`)) return;
try { await api.deleteUser(u.id); toast('User deleted', 'success'); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
{/* Row header — always visible */}
<button
onClick={() => { setOpen(o => !o); setShowReset(false); setEditName(false); }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 4px', background: 'none', border: 'none', cursor: 'pointer',
textAlign: 'left', color: 'var(--text-primary)',
}}
>
<Avatar user={u} size="sm" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{u.name}</span>
<span className={`role-badge role-${u.role}`}>{u.role}</span>
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
{!!u.is_default_admin && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Default Admin</span>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.email}</div>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 1 }}>
Last online: {(() => {
if (!u.last_online) return 'Never';
const d = new Date(u.last_online + 'Z');
const today = new Date(); today.setHours(0,0,0,0);
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
d.setHours(0,0,0,0);
if (d >= today) return 'Today';
if (d >= yesterday) return 'Yesterday';
return d.toISOString().slice(0,10);
})()}
</div>
{!!u.must_change_password && <div className="text-xs" style={{ color: 'var(--warning)' }}> Must change password</div>}
</div>
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ flexShrink: 0, transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none', color: 'var(--text-tertiary)' }}
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{/* Accordion panel */}
{open && !u.is_default_admin && (
<div style={{ padding: '4px 4px 14px 44px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Edit name */}
{editName ? (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
className="input"
style={{ flex: 1, fontSize: 13, padding: '5px 8px' }}
value={nameVal}
onChange={e => setNameVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditName(false); setNameVal(u.name); } }}
/>
<button className="btn btn-primary btn-sm" onClick={handleSaveName}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setEditName(false); setNameVal(u.name); }}></button>
</div>
) : (
<button
className="btn btn-secondary btn-sm"
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
onClick={() => { setEditName(true); setShowReset(false); }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit Name
</button>
)}
{/* Role selector */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
<select
value={roleWarning ? '' : u.role}
onChange={e => handleRole(e.target.value)}
className="input"
style={{ width: 140, padding: '5px 8px', fontSize: 13, borderColor: roleWarning ? '#e53935' : undefined }}
>
<option value="" disabled>User Role</option>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
{roleWarning && <span style={{ fontSize: 12, color: '#e53935' }}>Role Required</span>}
</div>
{/* Reset password */}
{showReset ? (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
className="input"
style={{ flex: 1, fontSize: 13, padding: '5px 8px' }}
type="text"
placeholder="New password (min 6)"
value={resetPw}
onChange={e => setResetPw(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleResetPw(); if (e.key === 'Escape') { setShowReset(false); setResetPw(''); } }}
/>
<button className="btn btn-primary btn-sm" onClick={handleResetPw}>Set</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowReset(false); setResetPw(''); }}></button>
</div>
) : (
<button
className="btn btn-secondary btn-sm"
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
onClick={() => { setShowReset(true); setEditName(false); }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Reset Password
</button>
)}
{/* Suspend / Activate / Delete */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{u.status === 'active' ? (
<button className="btn btn-secondary btn-sm" onClick={handleSuspend}>Suspend</button>
) : u.status === 'suspended' ? (
<button className="btn btn-secondary btn-sm" style={{ color: 'var(--success)' }} onClick={handleActivate}>Activate</button>
) : null}
<button className="btn btn-danger btn-sm" onClick={handleDelete}>Delete User</button>
</div>
</div>
)}
</div>
);
}
export default function UserManagerModal({ onClose }) {
const toast = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [tab, setTab] = useState('users');
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' });
const [csvFile, setCsvFile] = useState(null);
const [csvRows, setCsvRows] = useState([]);
const [csvInvalid, setCsvInvalid] = useState([]);
const [bulkResult, setBulkResult] = useState(null);
const [bulkLoading, setBulkLoading] = useState(false);
const fileRef = useRef(null);
const [userPass, setUserPass] = useState('user@1234');
const load = () => {
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
};
useEffect(() => {
load();
api.getSettings().then(({ settings }) => {
if (settings.user_pass) setUserPass(settings.user_pass);
}).catch(() => {});
}, []);
const filtered = users.filter(u =>
!search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase())
);
const handleCreate = async () => {
if (!form.name.trim() || !form.email.trim()) return toast('Name and email are required', 'error');
if (!isValidEmail(form.email)) return toast('Invalid email address', 'error');
if (!/\S+\s+\S+/.test(form.name.trim())) return toast('Name must be two words (First Last)', 'error');
setCreating(true);
try {
await api.createUser(form);
toast('User created', 'success');
setForm({ name: '', email: '', password: '', role: 'member' });
setTab('users');
load();
} catch (e) {
toast(e.message, 'error');
} finally {
setCreating(false);
}
};
const handleFileSelect = (e) => {
const file = e.target.files?.[0];
if (!file) return;
setCsvFile(file);
setBulkResult(null);
const reader = new FileReader();
reader.onload = (ev) => {
const { rows, invalid } = parseCSV(ev.target.result);
setCsvRows(rows);
setCsvInvalid(invalid);
};
reader.readAsText(file);
};
const handleBulkImport = async () => {
if (!csvRows.length) return;
setBulkLoading(true);
try {
const result = await api.bulkUsers(csvRows);
setBulkResult(result);
setCsvRows([]); setCsvFile(null); setCsvInvalid([]);
if (fileRef.current) fileRef.current.value = '';
load();
} catch (e) {
toast(e.message, 'error');
} finally {
setBulkLoading(false);
}
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 600, width: '100%' }}>
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div className="flex gap-2" style={{ marginBottom: 20 }}>
<button className={`btn btn-sm ${tab === 'users' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('users')}>All Users ({users.length})</button>
<button className={`btn btn-sm ${tab === 'create' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('create')}>+ Create User</button>
<button className={`btn btn-sm ${tab === 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('bulk')}>Bulk Import CSV</button>
</div>
{/* Users list — accordion */}
{tab === 'users' && (
<>
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)} />
{loading ? (
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
) : (
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{filtered.map(u => (
<UserRow key={u.id} u={u} onUpdated={load} />
))}
</div>
)}
</>
)}
{/* Create user */}
{tab === 'create' && (
<div className="flex-col gap-3">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Full Name <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(First Last)</span></label>
<input className="input" placeholder="Jane Smith" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Email</label>
<input className="input" type="email" placeholder="jane@example.com" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(blank = {userPass || 'USER_PASS'})</span></label>
<input className="input" type="text" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Role</label>
<select className="input" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))}>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>User must change password on first login. Duplicate names get a number suffix automatically.</p>
<button className="btn btn-primary" onClick={handleCreate} disabled={creating}>{creating ? 'Creating…' : 'Create User'}</button>
</div>
)}
{/* Bulk import */}
{tab === 'bulk' && (
<div className="flex-col gap-4">
<div className="card" style={{ background: 'var(--background)', border: '1px dashed var(--border)' }}>
<p className="text-sm font-medium" style={{ marginBottom: 6 }}>CSV Format</p>
<code style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'block', background: 'var(--surface)', padding: 8, borderRadius: 4, border: '1px solid var(--border)', whiteSpace: 'pre' }}>name,email,password,role{'\n'}Jane Smith,jane@company.local,,member{'\n'}Bob Jones,bob@company.com,TempPass1,admin</code>
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
Name and email are required. If left blank, Temp Password defaults to <strong>{userPass}</strong>, Role defaults to member. Lines with duplicate emails are skipped. Duplicate names get a number suffix.
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<label className="btn btn-secondary" style={{ cursor: 'pointer', margin: 0, flexShrink: 0 }}>
Select CSV File
<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileSelect} />
</label>
{csvFile && (
<span className="text-sm" style={{ color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{csvFile.name}
{csvRows.length > 0 && <span style={{ color: 'var(--text-tertiary)', marginLeft: 6 }}>({csvRows.length} valid)</span>}
</span>
)}
{csvRows.length > 0 && (
<button className="btn btn-primary" style={{ flexShrink: 0 }} onClick={handleBulkImport} disabled={bulkLoading}>
{bulkLoading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length !== 1 ? 's' : ''}`}
</button>
)}
</div>
{csvInvalid.length > 0 && (
<div style={{ background: 'rgba(229,57,53,0.07)', border: '1px solid #e53935', borderRadius: 'var(--radius)', padding: 10 }}>
<p className="text-sm font-medium" style={{ color: '#e53935', marginBottom: 6 }}>{csvInvalid.length} line{csvInvalid.length !== 1 ? 's' : ''} skipped invalid format</p>
<div style={{ maxHeight: 100, overflowY: 'auto' }}>
{csvInvalid.map((e, i) => (
<div key={i} style={{ fontSize: 12, padding: '2px 0', color: 'var(--text-secondary)' }}>
<code style={{ fontSize: 11 }}>{e.line}</code>
<span style={{ color: '#e53935', marginLeft: 8 }}> {e.reason}</span>
</div>
))}
</div>
</div>
)}
{bulkResult && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 12 }}>
<p className="text-sm font-medium" style={{ color: 'var(--success, #2e7d32)', marginBottom: bulkResult.skipped.length ? 8 : 0 }}>
{bulkResult.created.length} user{bulkResult.created.length !== 1 ? 's' : ''} created successfully
</p>
{bulkResult.skipped.length > 0 && (
<>
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 6 }}>{bulkResult.skipped.length} account{bulkResult.skipped.length !== 1 ? 's' : ''} skipped:</p>
<div style={{ maxHeight: 112, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
{bulkResult.skipped.map((s, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 10px', borderBottom: i < bulkResult.skipped.length - 1 ? '1px solid var(--border)' : 'none', fontSize: 13, gap: 12 }}>
<span style={{ color: 'var(--text-primary)' }}>{s.email}</span>
<span style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>{s.reason}</span>
</div>
))}
</div>
</>
)}
<button className="btn btn-secondary btn-sm" style={{ marginTop: 10 }} onClick={() => setBulkResult(null)}>Dismiss</button>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react';
import Avatar from './Avatar.jsx';
import { api } from '../utils/api.js';
import { useAuth } from '../contexts/AuthContext.jsx';
export default function UserProfilePopup({ user: profileUser, anchorEl, onClose, onDirectMessage }) {
const { user: currentUser } = useAuth();
const popupRef = useRef(null);
const [starting, setStarting] = useState(false);
const isSelf = currentUser?.id === profileUser?.id;
useEffect(() => {
const handler = (e) => {
if (popupRef.current && !popupRef.current.contains(e.target) &&
anchorEl && !anchorEl.contains(e.target)) {
onClose();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [anchorEl, onClose]);
useEffect(() => {
if (!popupRef.current || !anchorEl) return;
const anchor = anchorEl.getBoundingClientRect();
const popup = popupRef.current;
const viewportH = window.innerHeight;
const viewportW = window.innerWidth;
let top = anchor.bottom + 8;
let left = anchor.left;
if (top + 260 > viewportH) top = anchor.top - 268;
if (left + 220 > viewportW) left = viewportW - 228;
popup.style.top = `${top}px`;
popup.style.left = `${left}px`;
}, [anchorEl]);
const handleDM = async () => {
if (!onDirectMessage) return;
setStarting(true);
try {
const { group } = await api.createGroup({
type: 'private',
memberIds: [profileUser.id],
isDirect: true,
});
onClose();
onDirectMessage(group);
} catch (e) {
console.error('DM error', e);
} finally {
setStarting(false);
}
};
if (!profileUser) return null;
return (
<div
ref={popupRef}
style={{
position: 'fixed',
zIndex: 1000,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 16,
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
width: 220,
padding: '20px 16px 16px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
}}
>
<Avatar user={profileUser} size="xl" />
<div style={{ textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)', marginBottom: 2 }}>
{profileUser.name}
</div>
{profileUser.role === 'admin' && !profileUser.hide_admin_tag && (
<span className="role-badge role-admin" style={{ fontSize: 11 }}>Admin</span>
)}
</div>
{profileUser.about_me && (
<p style={{
fontSize: 13, color: 'var(--text-secondary)',
textAlign: 'center', lineHeight: 1.5,
marginTop: 4, wordBreak: 'break-word',
borderTop: '1px solid var(--border)',
paddingTop: 10, width: '100%',
}}>
{profileUser.about_me}
</p>
)}
{!isSelf && onDirectMessage && (
profileUser.allow_dm === 0 ? (
<p style={{
marginTop: 8,
textAlign: 'center',
fontSize: 12,
color: 'var(--text-tertiary)',
fontStyle: 'italic',
}}>
DMs disabled by user
</p>
) : (
<button
onClick={handleDM}
disabled={starting}
style={{
marginTop: 6,
width: '100%',
padding: '8px 0',
borderRadius: 'var(--radius)',
border: '1px solid var(--primary)',
background: 'transparent',
color: 'var(--primary)',
fontSize: 13,
fontWeight: 600,
cursor: starting ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
transition: 'background var(--transition), color var(--transition)',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--primary)'; e.currentTarget.style.color = 'white'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--primary)'; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
{starting ? 'Opening...' : 'Direct Message'}
</button>
)
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { createContext, useContext, useState, useEffect } from 'react';
import { api } from '../utils/api.js';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [mustChangePassword, setMustChangePassword] = useState(false);
useEffect(() => {
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
if (token) {
api.me()
.then(({ user }) => {
setUser(user);
setMustChangePassword(!!user.must_change_password);
})
.catch(() => {
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
})
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email, password, rememberMe) => {
const data = await api.login({ email, password, rememberMe });
if (rememberMe) {
localStorage.setItem('tc_token', data.token);
} else {
sessionStorage.setItem('tc_token', data.token);
}
setUser(data.user);
setMustChangePassword(!!data.mustChangePassword);
return data;
};
const logout = async () => {
try { await api.logout(); } catch {}
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
setUser(null);
setMustChangePassword(false);
};
// Listen for session displacement (another device logged in)
useEffect(() => {
const handler = () => {
setUser(null);
setMustChangePassword(false);
};
window.addEventListener('jama:session-displaced', handler);
return () => window.removeEventListener('jama:session-displaced', handler);
}, []);
const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates }));
return (
<AuthContext.Provider value={{ user, loading, mustChangePassword, setMustChangePassword, login, logout, updateUser }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

View File

@@ -0,0 +1,69 @@
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { io } from 'socket.io-client';
import { useAuth } from './AuthContext.jsx';
const SocketContext = createContext(null);
export function SocketProvider({ children }) {
const { user } = useAuth();
const socketRef = useRef(null);
const [connected, setConnected] = useState(false);
const [onlineUsers, setOnlineUsers] = useState(new Set());
useEffect(() => {
if (!user) {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setConnected(false);
}
return;
}
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
const socket = io('/', {
auth: { token },
transports: ['websocket'],
// Aggressive reconnection so mobile resume is fast
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 500,
reconnectionDelayMax: 3000,
timeout: 8000,
});
socketRef.current = socket;
socket.on('connect', () => {
setConnected(true);
socket.emit('users:online');
});
socket.on('disconnect', () => setConnected(false));
socket.on('users:online', ({ userIds }) => setOnlineUsers(new Set(userIds)));
socket.on('user:online', ({ userId }) => setOnlineUsers(prev => new Set([...prev, userId])));
socket.on('user:offline', ({ userId }) => setOnlineUsers(prev => { const s = new Set(prev); s.delete(userId); return s; }));
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
if (socketRef.current && !socketRef.current.connected) {
socketRef.current.connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
socket.disconnect();
socketRef.current = null;
};
}, [user?.id]);
return (
<SocketContext.Provider value={{ socket: socketRef.current, connected, onlineUsers }}>
{children}
</SocketContext.Provider>
);
}
export const useSocket = () => useContext(SocketContext);

View File

@@ -0,0 +1,28 @@
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
const ToastContext = createContext(null);
let toastIdCounter = 0;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const toast = useCallback((msg, type = 'default', duration = 3000) => {
const id = ++toastIdCounter;
setToasts(prev => [...prev, { id, msg, type }]);
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), duration);
}, []);
return (
<ToastContext.Provider value={toast}>
{children}
<div className="toast-container">
{toasts.map(t => (
<div key={t.id} className={`toast ${t.type}`}>{t.msg}</div>
))}
</div>
</ToastContext.Provider>
);
}
export const useToast = () => useContext(ToastContext);

469
frontend/src/index.css Normal file
View File

@@ -0,0 +1,469 @@
@import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600;700&family=Roboto:wght@300;400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #1a73e8;
--primary-dark: #1557b0;
--primary-light: #e8f0fe;
--surface: #ffffff;
--surface-variant: #f8f9fa;
--background: #f1f3f4;
--border: #e0e0e0;
--text-primary: #202124;
--text-secondary: #5f6368;
--text-tertiary: #9aa0a6;
--error: #d93025;
--success: #188038;
--warning: #e37400;
--bubble-out: #1a73e8;
--bubble-in: #f1f3f4;
--radius: 8px;
--radius-lg: 16px;
--radius-xl: 24px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.12);
--shadow-md: 0 2px 8px rgba(0,0,0,0.15);
--shadow-lg: 0 4px 20px rgba(0,0,0,0.18);
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--font: 'Google Sans', 'Roboto', sans-serif;
--font-scale: 1;
}
html, body, #root { height: 100%; min-width: 320px; font-family: var(--font); color: var(--text-primary); background: var(--background); }
/* Disable pull-to-refresh in PWA standalone mode — prevents viewport shift bug */
html {
overscroll-behavior-y: none;
font-size: 100%; /* inherits system font size — allows Android accessibility font scaling */
}
button { font-family: var(--font); cursor: pointer; border: none; background: none; }
input, textarea { font-family: var(--font); }
a { color: inherit; text-decoration: none; }
/* Scrollbars */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* Focus */
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Utils */
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.relative { position: relative; }
.absolute { position: absolute; }
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
.flex-1 { flex: 1; min-width: 0; }
.shrink-0 { flex-shrink: 0; }
.text-sm { font-size: 13px; }
.text-xs { font-size: 11px; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.opacity-60 { opacity: 0.6; }
.pointer { cursor: pointer; }
.rounded { border-radius: var(--radius); }
.rounded-full { border-radius: 9999px; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 20px; border-radius: 20px; font-size: 14px; font-weight: 500;
transition: var(--transition); white-space: nowrap;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); box-shadow: var(--shadow-sm); }
.btn-secondary { background: transparent; color: var(--primary); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--primary-light); }
.btn-ghost { background: transparent; color: var(--text-secondary); }
.btn-ghost:hover { background: var(--background); color: var(--text-primary); }
.btn-danger { background: var(--error); color: white; }
.btn-danger:hover { opacity: 0.9; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-icon { padding: 8px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; }
.btn-icon:hover { background: var(--background); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Inputs */
.input {
width: 100%; padding: 10px 14px; border: 1px solid var(--border);
border-radius: var(--radius); font-size: 14px; background: white;
transition: border-color var(--transition);
color: var(--text-primary);
}
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(26,115,232,0.15); }
.input::placeholder { color: var(--text-tertiary); }
/* Card */
.card {
background: white; border-radius: var(--radius-lg); padding: 20px;
box-shadow: var(--shadow-sm); border: 1px solid var(--border);
}
/* Modal overlay */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
z-index: 1000; padding: 16px;
animation: fadeIn 150ms ease;
}
.modal {
background: white; border-radius: var(--radius-xl); padding: 24px;
width: 100%; max-width: 480px; box-shadow: var(--shadow-lg);
animation: slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1);
max-height: 90vh; overflow-y: auto;
}
.modal-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; }
/* Avatar */
.avatar {
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 14px; flex-shrink: 0; overflow: hidden;
background: var(--primary); color: white; user-select: none;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.avatar-sm { width: 32px; height: 32px; font-size: 12px; }
.avatar-md { width: 40px; height: 40px; font-size: 15px; }
.avatar-lg { width: 48px; height: 48px; font-size: 18px; }
.avatar-xl { width: 72px; height: 72px; font-size: 24px; }
/* Badge */
.badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px;
font-size: 11px; font-weight: 600; background: var(--primary); color: white;
}
/* Animations */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideIn { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
@keyframes spin { to { transform: rotate(360deg); } }
/* Loading */
.spinner {
width: 24px; height: 24px; border: 3px solid var(--border);
border-top-color: var(--primary); border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Toast */
.toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 12px 20px; border-radius: var(--radius); background: #323232; color: white;
font-size: 14px; box-shadow: var(--shadow-md); animation: slideIn 200ms ease;
max-width: 320px;
}
.toast.error { background: var(--error); }
.toast.success { background: var(--success); }
/* Chip */
.chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 16px; font-size: 12px; font-weight: 500;
background: var(--primary-light); color: var(--primary);
}
.chip-remove { cursor: pointer; opacity: 0.7; font-size: 14px; }
.chip-remove:hover { opacity: 1; }
/* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
/* Warning banner */
.warning-banner {
background: #fff3e0; border: 1px solid #ff9800; border-radius: var(--radius);
padding: 12px 16px; font-size: 13px; color: #e65100; display: flex; gap: 8px; align-items: flex-start;
}
/* Role badge */
.role-badge {
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; text-transform: uppercase;
}
.role-admin { background: #fce8e6; color: #c5221f; }
.role-member { background: var(--primary-light); color: var(--primary); }
.status-suspended { background: #fff3e0; color: #e65100; }
.settings-section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 12px;
}
/* ── Dark mode ─────────────────────────────────────────── */
[data-theme="dark"] {
--primary: #6ab0f5;
--primary-dark: #4d9de0;
--primary-light: #1a2d4a;
--surface: #1e1e2e;
--surface-variant: #252535;
--background: #13131f;
--border: #2e2e45;
--text-primary: #e2e2f0;
--text-secondary: #9898b8;
--text-tertiary: #606080;
--bubble-out: #4d8fd4;
--bubble-in: #252535;
}
[data-theme="dark"] body,
[data-theme="dark"] html,
[data-theme="dark"] #root { background: var(--background); }
[data-theme="dark"] .modal { background: var(--surface); }
[data-theme="dark"] .footer-menu { background: var(--surface); }
[data-theme="dark"] .sidebar { background: var(--surface); }
[data-theme="dark"] .chat-window { background: var(--background); }
[data-theme="dark"] .chat-header { background: var(--surface); border-color: var(--border); }
[data-theme="dark"] .messages-container { background: var(--background); }
[data-theme="dark"] .input { background: var(--surface-variant); border-color: var(--border); color: var(--text-primary); }
[data-theme="dark"] .card { background: var(--surface); border-color: var(--border); }
[data-theme="dark"] .message-input-area { background: var(--surface); border-color: var(--border); }
[data-theme="dark"] .message-input-wrap { background: var(--surface-variant); border-color: var(--border); }
[data-theme="dark"] .msg-input:focus { background: var(--surface-variant); color: var(--text-primary); }
/* Light mode: focused input goes white so it pops from the grey background */
[data-theme="light"] .msg-input:focus, :root:not([data-theme="dark"]) .msg-input:focus { background: white; }
[data-theme="dark"] .btn-secondary { border-color: var(--border); color: var(--primary); }
[data-theme="dark"] .btn-secondary:hover { background: var(--primary-light); }
[data-theme="dark"] .search-input { background: var(--surface-variant); color: var(--text-primary); }
[data-theme="dark"] .group-item:hover { background: var(--surface-variant); }
[data-theme="dark"] .group-item.active { background: var(--primary-light); }
[data-theme="dark"] .user-footer-btn:hover { background: var(--surface-variant); }
[data-theme="dark"] .footer-menu-item:hover { background: var(--surface-variant); }
[data-theme="dark"] .footer-menu-item.danger:hover { background: #3a1a1a; }
[data-theme="dark"] .btn-icon { color: var(--text-primary); }
[data-theme="dark"] .btn-icon:hover { background: var(--surface-variant); }
[data-theme="dark"] .sidebar-header { background: var(--surface); border-color: var(--border); }
[data-theme="dark"] .newchat-btn { background: var(--surface-variant); border-color: var(--primary); color: var(--primary); }
[data-theme="dark"] .newchat-btn:hover { background: var(--primary); color: white; }
[data-theme="dark"] .newchat-fab { background: var(--primary); }
[data-theme="dark"] .msg-actions { background: var(--surface); border-color: var(--border); }
[data-theme="dark"] .reaction-btn:hover { background: var(--surface-variant); }
[data-theme="dark"] .emoji-picker-wrap { background: var(--surface); border-color: var(--border); }
[data-theme="dark"] .reply-preview { background: var(--surface-variant); border-color: var(--primary); }
[data-theme="dark"] .load-more-btn { background: var(--surface-variant); color: var(--text-secondary); }
[data-theme="dark"] .readonly-bar { background: var(--surface); border-color: var(--border); color: var(--text-secondary); }
[data-theme="dark"] .warning-banner { background: #2a1f00; border-color: #6a4a00; color: #ffb74d; }
/* ── About Modal ─────────────────────────────────────── */
.about-modal {
max-width: 420px;
text-align: center;
position: relative;
padding: 32px 28px 24px;
}
.about-close {
position: absolute;
top: 12px;
right: 12px;
}
.about-hero {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 28px;
}
.about-logo {
width: 80px;
height: 80px;
object-fit: contain;
margin-bottom: 12px;
}
.about-appname {
font-size: 26px;
font-weight: 800;
color: var(--primary);
margin: 0 0 4px;
}
.about-tagline {
font-size: 13px;
color: var(--text-tertiary);
font-style: italic;
margin: 0;
}
.about-table {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: visible;
margin-bottom: 20px;
text-align: left;
}
.about-row {
display: flex;
align-items: flex-start;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
gap: 12px;
}
.about-row:last-child { border-bottom: none; }
.about-label {
font-size: 12px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 90px;
flex-shrink: 0;
padding-top: 1px;
}
.about-value {
font-size: 14px;
color: var(--text-primary);
flex: 1;
min-width: 0;
overflow-wrap: break-word;
word-break: normal;
white-space: normal;
line-height: 1.5;
}
.about-mono {
font-family: monospace;
font-size: 13px;
}
.about-link {
color: var(--primary);
text-decoration: underline;
}
.about-link:hover { opacity: 0.8; }
.about-footer {
font-size: 12px;
color: var(--text-tertiary);
margin: 0;
}
[data-theme="dark"] .about-table { border-color: var(--border); }
[data-theme="dark"] .about-row { border-color: var(--border); }
[data-theme="dark"] .attach-menu {
background: var(--surface);
border-color: var(--border);
}
[data-theme="dark"] .attach-item {
color: var(--text-primary);
}
[data-theme="dark"] .attach-item:hover {
background: var(--primary-light);
color: var(--primary);
}
[data-theme="dark"] .attach-item svg {
color: var(--text-secondary);
}
[data-theme="dark"] .mention-dropdown {
background: var(--surface);
border-color: var(--border);
}
[data-theme="dark"] .mention-item {
color: var(--text-primary);
}
[data-theme="dark"] .mention-item:hover,
[data-theme="dark"] .mention-item.active {
background: var(--primary-light);
color: var(--primary);
}
[data-theme="dark"] .mention-role {
color: var(--text-tertiary);
}
[data-theme="dark"] .mention-avatar {
background: var(--primary);
}
/* Night mode: .in bubble (dark surface) — base mention colour is too dark, use light primary */
[data-theme="dark"] .in .mention {
color: #6ab0f5;
background: rgba(106,176,245,0.15);
}
/* Night mode: .out bubble — white stays white, just ensure background tint works on darker primary */
[data-theme="dark"] .out .mention {
color: #ffffff;
background: rgba(255,255,255,0.25);
}
[data-theme="dark"] .reaction-btn {
background: var(--surface-variant);
border-color: var(--border);
}
[data-theme="dark"] .reaction-btn:hover {
background: var(--primary-light);
}
/* ── Help Modal ─────────────────────────────────────────────── */
.help-modal {
width: min(720px, 94vw);
max-height: 85vh;
display: flex;
flex-direction: column;
}
.help-content {
flex: 1;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
background: var(--surface-variant);
margin-bottom: 16px;
}
.help-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 4px;
border-top: 1px solid var(--border);
padding-top: 12px;
}
/* Markdown typography */
.help-markdown h1 { font-size: 1.5rem; font-weight: 700; margin: 0 0 16px; color: var(--text-primary); }
.help-markdown h2 { font-size: 1.15rem; font-weight: 700; margin: 24px 0 10px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.help-markdown h3 { font-size: 1rem; font-weight: 600; margin: 16px 0 6px; color: var(--text-primary); }
.help-markdown p { margin: 0 0 12px; line-height: 1.65; color: var(--text-secondary); font-size: 14px; }
.help-markdown ul, .help-markdown ol { margin: 0 0 12px 20px; color: var(--text-secondary); font-size: 14px; }
.help-markdown li { margin-bottom: 4px; line-height: 1.6; }
.help-markdown strong { font-weight: 600; color: var(--text-primary); }
.help-markdown em { font-style: italic; }
.help-markdown code { font-family: monospace; font-size: 13px; background: var(--background); padding: 1px 5px; border-radius: 4px; color: var(--primary); }
.help-markdown pre { background: var(--background); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; overflow-x: auto; margin: 0 0 12px; }
.help-markdown pre code { background: none; padding: 0; color: var(--text-primary); }
.help-markdown blockquote { border-left: 3px solid var(--primary); margin: 0 0 12px; padding: 6px 14px; background: var(--primary-light); border-radius: 0 var(--radius) var(--radius) 0; }
.help-markdown blockquote p { margin: 0; color: var(--text-secondary); }
.help-markdown hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
.help-markdown a { color: var(--primary); text-decoration: underline; }
[data-theme="dark"] .help-markdown code { background: var(--surface); }
[data-theme="dark"] .help-markdown pre { background: var(--surface); }
[data-theme="dark"] .help-markdown blockquote { background: rgba(99,102,241,0.1); }
/* Mention picker online dot */
.mention-avatar-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.mention-online-dot {
position: absolute;
bottom: 0;
right: 0;
width: 9px;
height: 9px;
background: #34a853;
border-radius: 50%;
border: 2px solid var(--surface);
pointer-events: none;
}

95
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,95 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('[SW] Registered, scope:', reg.scope))
.catch(err => console.error('[SW] Registration failed:', err));
});
}
// ─── Touch gesture handler ───────────────────────────────────────────────────
// Handles two behaviours in one unified listener set to avoid conflicts:
//
// 1. PINCH → font scale only (not viewport zoom).
// viewport has user-scalable=no so the browser never zooms the layout.
// We intercept the pinch and adjust --font-scale on <html> instead,
// which scales only text (rem-based font sizes). Persisted to localStorage.
// On first launch, html { font-size: 100% } inherits the Android system
// font size as the 1rem baseline automatically.
//
// 2. PULL-TO-REFRESH → blocked in PWA standalone mode only.
(function () {
const LS_KEY = 'jama_font_scale';
const MIN_SCALE = 0.8;
const MAX_SCALE = 2.0;
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
// Restore saved font scale on launch
const saved = parseFloat(localStorage.getItem(LS_KEY));
let currentScale = (saved >= MIN_SCALE && saved <= MAX_SCALE) ? saved : 1.0;
document.documentElement.style.setProperty('--font-scale', currentScale);
let pinchStartDist = null;
let pinchStartScale = currentScale;
let singleStartY = 0;
function getTouchDist(e) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
document.addEventListener('touchstart', function (e) {
if (e.touches.length === 2) {
pinchStartDist = getTouchDist(e);
pinchStartScale = currentScale;
} else if (e.touches.length === 1) {
singleStartY = e.touches[0].clientY;
}
}, { passive: true });
document.addEventListener('touchmove', function (e) {
if (e.touches.length === 2 && pinchStartDist !== null) {
// Two-finger pinch: scale fonts, not viewport
e.preventDefault();
const ratio = getTouchDist(e) / pinchStartDist;
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinchStartScale * ratio));
currentScale = Math.round(newScale * 100) / 100;
document.documentElement.style.setProperty('--font-scale', currentScale);
} else if (e.touches.length === 1 && isStandalone) {
// Single finger: block pull-to-refresh at top of page
const dy = e.touches[0].clientY - singleStartY;
if (dy > 0 && document.documentElement.scrollTop === 0 && document.body.scrollTop === 0) {
e.preventDefault();
}
}
}, { passive: false });
document.addEventListener('touchend', function (e) {
if (e.touches.length < 2 && pinchStartDist !== null) {
pinchStartDist = null;
localStorage.setItem(LS_KEY, currentScale);
}
}, { passive: true });
})();
// Clear badge count when user focuses the app
window.addEventListener('focus', () => {
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
navigator.serviceWorker?.controller?.postMessage({ type: 'CLEAR_BADGE' });
});
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
export default function ChangePassword() {
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const { setMustChangePassword } = useAuth();
const toast = useToast();
const nav = useNavigate();
const submit = async (e) => {
e.preventDefault();
if (next !== confirm) return toast('Passwords do not match', 'error');
if (next.length < 8) return toast('Password must be at least 8 characters', 'error');
setLoading(true);
try {
await api.changePassword({ currentPassword: current, newPassword: next });
setMustChangePassword(false);
toast('Password changed!', 'success');
nav('/');
} catch (err) {
toast(err.message, 'error');
} finally {
setLoading(false);
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--background)', padding: 20 }}>
<div className="card" style={{ width: '100%', maxWidth: 420 }}>
<h2 style={{ marginBottom: 8, fontSize: 22, fontWeight: 700 }}>Change Password</h2>
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
You must set a new password before continuing.
</p>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label>
<input className="input" type="password" value={current} onChange={e => setCurrent(e.target.value)} required />
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label>
<input className="input" type="password" value={next} onChange={e => setNext(e.target.value)} required />
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Confirm New Password</label>
<input className="input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required />
</div>
<button className="btn btn-primary" type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Set New Password'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
.chat-layout {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--background);
}
/* Global top bar */
.global-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
height: 72px;
min-height: 72px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 20;
flex-shrink: 0;
}
.global-bar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.global-bar-logo {
width: 40px;
height: 40px;
object-fit: contain;
border-radius: 6px;
flex-shrink: 0;
}
.global-bar-title {
font-size: 22px;
font-weight: 700;
color: var(--primary);
}
.global-bar-offline {
display: flex;
align-items: center;
gap: 6px;
color: #e53935;
font-size: 13px;
font-weight: 500;
}
.offline-label {
font-size: 13px;
}
/* Body below global bar */
.chat-body {
display: flex;
flex: 1;
min-height: 0; /* allows body to shrink when mobile keyboard resizes viewport */
overflow: hidden;
}
@media (max-width: 767px) {
.chat-layout {
position: relative;
}
.chat-body {
overflow: hidden;
min-height: 0;
}
.global-bar {
height: 56px;
min-height: 56px;
}
}
[data-theme="dark"] .global-bar {
background: var(--surface);
border-color: var(--border);
}
.no-chat-selected {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-tertiary);
font-size: 14px;
gap: 4px;
user-select: none;
}

349
frontend/src/pages/Chat.jsx Normal file
View File

@@ -0,0 +1,349 @@
import { useState, useEffect, useCallback } from 'react';
import { useSocket } from '../contexts/SocketContext.jsx';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
import Sidebar from '../components/Sidebar.jsx';
import ChatWindow from '../components/ChatWindow.jsx';
import ProfileModal from '../components/ProfileModal.jsx';
import UserManagerModal from '../components/UserManagerModal.jsx';
import SettingsModal from '../components/SettingsModal.jsx';
import BrandingModal from '../components/BrandingModal.jsx';
import NewChatModal from '../components/NewChatModal.jsx';
import GlobalBar from '../components/GlobalBar.jsx';
import AboutModal from '../components/AboutModal.jsx';
import HelpModal from '../components/HelpModal.jsx';
import './Chat.css';
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
return outputArray;
}
export default function Chat() {
const { socket } = useSocket();
const { user } = useAuth();
const toast = useToast();
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
const [onlineUserIds, setOnlineUserIds] = useState(new Set());
const [activeGroupId, setActiveGroupId] = useState(null);
const [notifications, setNotifications] = useState([]);
const [unreadGroups, setUnreadGroups] = useState(new Map());
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help'
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true);
// Check if help should be shown on login
useEffect(() => {
api.getHelpStatus()
.then(({ dismissed }) => {
setHelpDismissed(dismissed);
if (!dismissed) setModal('help');
})
.catch(() => {});
}, []);
useEffect(() => {
const handle = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
if (!mobile) setShowSidebar(true);
};
window.addEventListener('resize', handle);
return () => window.removeEventListener('resize', handle);
}, []);
const loadGroups = useCallback(() => {
api.getGroups().then(setGroups).catch(() => {});
}, []);
useEffect(() => { loadGroups(); }, [loadGroups]);
// Register / refresh push subscription
useEffect(() => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
const registerPush = async () => {
try {
const permission = Notification.permission;
if (permission === 'denied') return;
const reg = await navigator.serviceWorker.ready;
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
let sub = await reg.pushManager.getSubscription();
if (!sub) {
// First time or subscription was lost — request permission then subscribe
const granted = permission === 'granted'
? 'granted'
: await Notification.requestPermission();
if (granted !== 'granted') return;
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
}
// Always re-register subscription with the server (keeps it fresh on mobile)
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(sub.toJSON()),
});
console.log('[Push] Subscription registered');
} catch (e) {
console.warn('[Push] Subscription failed:', e.message);
}
};
registerPush();
// Bug A fix: re-register push subscription when app returns to foreground
// Mobile browsers can drop push subscriptions when the app is backgrounded
const handleVisibility = () => {
if (document.visibilityState === 'visible') registerPush();
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
// Socket message events to update group previews
useEffect(() => {
if (!socket) return;
const handleNewMsg = (msg) => {
// Update group preview text
setGroups(prev => {
const updateGroup = (g) => g.id === msg.group_id
? { ...g, last_message: msg.content || (msg.image_url ? '📷 Image' : ''), last_message_at: msg.created_at, last_message_user_id: msg.user_id }
: g;
const updatedPrivate = prev.privateGroups.map(updateGroup)
.sort((a, b) => {
if (!a.last_message_at && !b.last_message_at) return 0;
if (!a.last_message_at) return 1;
if (!b.last_message_at) return -1;
return new Date(b.last_message_at) - new Date(a.last_message_at);
});
return {
publicGroups: prev.publicGroups.map(updateGroup),
privateGroups: updatedPrivate,
};
});
// Don't badge own messages
if (msg.user_id === user?.id) return;
// Bug C fix: count unread even in the active group when window is hidden/minimized
const groupIsActive = msg.group_id === activeGroupId;
const windowHidden = document.visibilityState === 'hidden';
setUnreadGroups(prev => {
if (groupIsActive && !windowHidden) return prev; // visible & active: no badge
const next = new Map(prev);
next.set(msg.group_id, (next.get(msg.group_id) || 0) + 1);
return next;
});
};
const handleNotification = (notif) => {
if (notif.type === 'private_message') {
// Badge is already handled by handleNewMsg via message:new socket event.
// Nothing to do here for the socket path.
} else if (notif.type === 'support') {
// A support request was submitted — reload groups so Support group appears in sidebar
loadGroups();
} else {
setNotifications(prev => [notif, ...prev]);
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
}
};
socket.on('message:new', handleNewMsg);
socket.on('notification:new', handleNotification);
// Group list real-time updates
const handleGroupNew = ({ group }) => {
// Join the socket room for this new group
socket.emit('group:join-room', { groupId: group.id });
// Reload the full group list so name/metadata is correct
loadGroups();
};
const handleGroupDeleted = ({ groupId }) => {
// Leave the socket room so we stop receiving events for this group
socket.emit('group:leave-room', { groupId });
setGroups(prev => ({
publicGroups: prev.publicGroups.filter(g => g.id !== groupId),
privateGroups: prev.privateGroups.filter(g => g.id !== groupId),
}));
setActiveGroupId(prev => {
if (prev === groupId) {
if (isMobile) setShowSidebar(true);
return null;
}
return prev;
});
setUnreadGroups(prev => { const next = new Map(prev); next.delete(groupId); return next; });
};
const handleGroupUpdated = ({ group }) => {
setGroups(prev => {
const update = g => g.id === group.id ? { ...g, ...group } : g;
return {
publicGroups: prev.publicGroups.map(update),
privateGroups: prev.privateGroups.map(update),
};
});
};
// Session displaced: another login on the same device type kicked us out
const handleSessionDisplaced = ({ device: displacedDevice }) => {
// Only act if it's our device slot that was taken over
// (The server emits to user room so all sockets of this user receive it;
// our socket's device is embedded in the socket but we can't read it here,
// so we force logout unconditionally — the new session will reconnect cleanly)
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
};
// Online presence
const handleUserOnline = ({ userId }) => setOnlineUserIds(prev => new Set([...prev, Number(userId)]));
const handleUserOffline = ({ userId }) => setOnlineUserIds(prev => { const n = new Set(prev); n.delete(Number(userId)); return n; });
const handleUsersOnline = ({ userIds }) => setOnlineUserIds(new Set((userIds || []).map(Number)));
socket.on('user:online', handleUserOnline);
socket.on('user:offline', handleUserOffline);
socket.on('users:online', handleUsersOnline);
// Request current online list on connect
socket.emit('users:online');
socket.on('group:new', handleGroupNew);
socket.on('group:deleted', handleGroupDeleted);
socket.on('group:updated', handleGroupUpdated);
socket.on('session:displaced', handleSessionDisplaced);
// Bug B fix: on reconnect, reload groups to catch any messages missed while offline
const handleReconnect = () => { loadGroups(); };
socket.on('connect', handleReconnect);
// Bug B fix: also reload on visibility restore if socket is already connected
const handleVisibility = () => {
if (document.visibilityState === 'visible' && socket.connected) {
loadGroups();
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => {
socket.off('message:new', handleNewMsg);
socket.off('notification:new', handleNotification);
socket.off('group:new', handleGroupNew);
socket.off('group:deleted', handleGroupDeleted);
socket.off('group:updated', handleGroupUpdated);
socket.off('user:online', handleUserOnline);
socket.off('user:offline', handleUserOffline);
socket.off('users:online', handleUsersOnline);
socket.off('connect', handleReconnect);
socket.off('session:displaced', handleSessionDisplaced);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
const selectGroup = (id) => {
setActiveGroupId(id);
if (isMobile) {
setShowSidebar(false);
// Push a history entry so swipe-back returns to sidebar instead of exiting the app
window.history.pushState({ jamaChatOpen: true }, '');
}
// Clear notifications and unread count for this group
setNotifications(prev => prev.filter(n => n.groupId !== id));
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
};
// Handle browser back gesture on mobile — return to sidebar instead of exiting
useEffect(() => {
const handlePopState = (e) => {
if (isMobile && activeGroupId) {
setShowSidebar(true);
setActiveGroupId(null);
// Push another entry so subsequent back gestures are also intercepted
window.history.pushState({ jamaChatOpen: true }, '');
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isMobile, activeGroupId]);
// Update page title AND PWA app badge with total unread count
useEffect(() => {
const totalUnread = [...unreadGroups.values()].reduce((a, b) => a + b, 0);
// Strip any existing badge prefix to get the clean base title
const base = document.title.replace(/^\(\d+\)\s*/, '');
document.title = totalUnread > 0 ? `(${totalUnread}) ${base}` : base;
// PWA app icon badge (Chrome/Edge desktop + Android, Safari 16.4+)
if ('setAppBadge' in navigator) {
if (totalUnread > 0) {
navigator.setAppBadge(totalUnread).catch(() => {});
} else {
navigator.clearAppBadge().catch(() => {});
}
}
}, [unreadGroups]);
const activeGroup = [
...(groups.publicGroups || []),
...(groups.privateGroups || [])
].find(g => g.id === activeGroupId);
return (
<div className="chat-layout">
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} />
<div className="chat-body">
{(!isMobile || showSidebar) && (
<Sidebar
groups={groups}
activeGroupId={activeGroupId}
onSelectGroup={selectGroup}
notifications={notifications}
unreadGroups={unreadGroups}
onNewChat={() => setModal('newchat')}
onProfile={() => setModal('profile')}
onUsers={() => setModal('users')}
onSettings={() => setModal('settings')}
onBranding={() => setModal('branding')}
onGroupsUpdated={loadGroups}
isMobile={isMobile}
onAbout={() => setModal('about')}
onHelp={() => setModal('help')}
onlineUserIds={onlineUserIds}
/>
)}
{(!isMobile || !showSidebar) && (
<ChatWindow
group={activeGroup}
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
onGroupUpdated={loadGroups}
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
onlineUserIds={onlineUserIds}
/>
)}
</div>
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
</div>
);
}

View File

@@ -0,0 +1,100 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e8f0fe 0%, #f1f3f4 50%, #e8f0fe 100%);
padding: 20px;
}
.login-card {
background: white;
border-radius: 24px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.logo-img {
width: 72px;
height: 72px;
border-radius: 16px;
object-fit: cover;
margin-bottom: 16px;
}
.login-logo h1 {
font-size: 28px;
font-weight: 700;
color: #202124;
margin-bottom: 4px;
}
.login-logo p {
color: #5f6368;
font-size: 15px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 14px;
font-weight: 500;
color: #5f6368;
}
.remember-me {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #5f6368;
cursor: pointer;
}
.remember-me input[type="checkbox"] {
accent-color: #1a73e8;
width: 16px;
height: 16px;
}
.login-footer {
margin-top: 24px;
text-align: center;
font-size: 13px;
color: #9aa0a6;
display: flex;
flex-direction: column;
gap: 4px;
}
.support-link {
background: none;
border: none;
color: var(--primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 0;
text-decoration: underline;
text-underline-offset: 2px;
}
.support-link:hover {
color: var(--primary-dark, #1557b0);
}

View File

@@ -0,0 +1,122 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
import './Login.css';
import SupportModal from '../components/SupportModal.jsx';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
const [showSupport, setShowSupport] = useState(false);
const [settings, setSettings] = useState({});
const { login } = useAuth();
const toast = useToast();
const nav = useNavigate();
useEffect(() => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const data = await login(email, password, rememberMe);
if (data.mustChangePassword) {
nav('/change-password');
} else {
nav('/');
}
} catch (err) {
if (err.message === 'suspended') {
toast(`Your account has been suspended. Contact: ${err.adminEmail || 'your admin'} for assistance.`, 'error', 8000);
} else {
toast(err.message || 'Login failed', 'error');
}
} finally {
setLoading(false);
}
};
// Handle suspension error from API directly
const handleLoginError = async (email, password, rememberMe) => {
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, rememberMe })
});
const data = await res.json();
if (!res.ok) {
if (data.error === 'suspended') {
toast(`Your account has been suspended. Contact ${data.adminEmail || 'your administrator'} for assistance.`, 'error', 8000);
} else {
toast(data.error || 'Login failed', 'error');
}
return;
}
// Success handled by login function above
} finally {
setLoading(false);
}
};
const appName = settings.app_name || 'jama';
const logoUrl = settings.logo_url;
return (
<div className="login-page">
<div className="login-card">
<div className="login-logo">
{logoUrl ? (
<img src={logoUrl} alt={appName} className="logo-img" />
) : (
<img src="/icons/jama.png" alt="jama" className="logo-img" />
)}
<h1>{appName}</h1>
<p>Sign in to continue</p>
</div>
{settings.pw_reset_active === 'true' && (
<div className="warning-banner" style={{ marginBottom: 16 }}>
<span></span>
<span><strong>ADMPW_RESET is enabled.</strong> The admin password is being reset on each restart. Disable ADMPW_RESET in your environment to stop this behavior.</span>
</div>
)}
<form onSubmit={handleSubmit} className="login-form">
<div className="field">
<label>Email</label>
<input className="input" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus placeholder="your@email.com" />
</div>
<div className="field">
<label>Password</label>
<input className="input" type="password" value={password} onChange={e => setPassword(e.target.value)} required placeholder="••••••••" />
</div>
<label className="remember-me">
<input type="checkbox" checked={rememberMe} onChange={e => setRememberMe(e.target.checked)} />
<span>Remember me</span>
</label>
<button className="btn btn-primary w-full" type="submit" disabled={loading}>
{loading ? <span className="spinner" style={{ width: 18, height: 18 }} /> : 'Sign in'}
</button>
</form>
<div className="login-footer">
<button className="support-link" onClick={() => setShowSupport(true)}>
Need help? Contact Support
</button>
</div>
{showSupport && <SupportModal onClose={() => setShowSupport(false)} />}
</div>
</div>
);
}

130
frontend/src/utils/api.js Normal file
View File

@@ -0,0 +1,130 @@
const BASE = '/api';
function getToken() {
return localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
}
// SQLite datetime('now') returns "YYYY-MM-DD HH:MM:SS" with no timezone marker.
// Browsers parse bare strings like this as LOCAL time, but the value is actually UTC.
// Appending 'Z' forces correct UTC interpretation so local display is always right.
export function parseTS(ts) {
if (!ts) return new Date(NaN);
// Already has timezone info (contains T and Z/+ or ends in Z) — leave alone
if (/Z$|[+-]\d{2}:\d{2}$/.test(ts) || (ts.includes('T') && ts.includes('Z'))) return new Date(ts);
// Replace the space separator SQLite uses and append Z
return new Date(ts.replace(' ', 'T') + 'Z');
}
async function req(method, path, body, opts = {}) {
const token = getToken();
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
let fetchOpts = { method, headers };
if (body instanceof FormData) {
fetchOpts.body = body;
} else if (body) {
headers['Content-Type'] = 'application/json';
fetchOpts.body = JSON.stringify(body);
}
const res = await fetch(BASE + path, fetchOpts);
const data = await res.json();
if (!res.ok) {
// Session displaced by a new login elsewhere — force logout
if (res.status === 401 && data.error?.includes('Session expired')) {
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
}
throw new Error(data.error || 'Request failed');
}
return data;
}
export const api = {
// Auth
login: (body) => req('POST', '/auth/login', body),
submitSupport: (body) => req('POST', '/auth/support', body),
logout: () => req('POST', '/auth/logout'),
me: () => req('GET', '/auth/me'),
changePassword: (body) => req('POST', '/auth/change-password', body),
// Users
getUsers: () => req('GET', '/users'),
searchUsers: (q, groupId) => req('GET', `/users/search?q=${encodeURIComponent(q)}${groupId ? `&groupId=${groupId}` : ''}`),
createUser: (body) => req('POST', '/users', body),
bulkUsers: (users) => req('POST', '/users/bulk', { users }),
updateName: (id, name) => req('PATCH', `/users/${id}/name`, { name }),
updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }),
resetPassword: (id, password) => req('PATCH', `/users/${id}/reset-password`, { password }),
suspendUser: (id) => req('PATCH', `/users/${id}/suspend`),
activateUser: (id) => req('PATCH', `/users/${id}/activate`),
deleteUser: (id) => req('DELETE', `/users/${id}`),
checkDisplayName: (name) => req('GET', `/users/check-display-name?name=${encodeURIComponent(name)}`),
updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag, allowDm }
uploadAvatar: (file) => {
const form = new FormData(); form.append('avatar', file);
return req('POST', '/users/me/avatar', form);
},
// Groups
getGroups: () => req('GET', '/groups'),
createGroup: (body) => req('POST', '/groups', body),
renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }),
setCustomGroupName: (id, name) => req('PATCH', `/groups/${id}/custom-name`, { name }),
getHelp: () => req('GET', '/help'),
getHelpStatus: () => req('GET', '/help/status'),
dismissHelp: (dismissed) => req('POST', '/help/dismiss', { dismissed }),
getMembers: (id) => req('GET', `/groups/${id}/members`),
addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }),
removeMember: (groupId, userId) => req('DELETE', `/groups/${groupId}/members/${userId}`),
leaveGroup: (id) => req('DELETE', `/groups/${id}/leave`),
takeOwnership: (id) => req('POST', `/groups/${id}/take-ownership`),
deleteGroup: (id) => req('DELETE', `/groups/${id}`),
// Messages
getMessages: (groupId, before) => req('GET', `/messages/group/${groupId}${before ? `?before=${before}` : ''}`),
sendMessage: (groupId, body) => req('POST', `/messages/group/${groupId}`, body),
uploadImage: (groupId, file, extra = {}) => {
const form = new FormData();
form.append('image', file);
if (extra.replyToId) form.append('replyToId', extra.replyToId);
if (extra.content) form.append('content', extra.content);
return req('POST', `/messages/group/${groupId}/image`, form);
},
deleteMessage: (id) => req('DELETE', `/messages/${id}`),
toggleReaction: (id, emoji) => req('POST', `/messages/${id}/reactions`, { emoji }),
// Settings
getSettings: () => req('GET', '/settings'),
updateAppName: (name) => req('PATCH', '/settings/app-name', { name }),
updateColors: (body) => req('PATCH', '/settings/colors', body),
uploadLogo: (file) => {
const form = new FormData(); form.append('logo', file);
return req('POST', '/settings/logo', form);
},
uploadIconNewChat: (file) => {
const form = new FormData(); form.append('icon', file);
return req('POST', '/settings/icon-newchat', form);
},
uploadIconGroupInfo: (file) => {
const form = new FormData(); form.append('icon', file);
return req('POST', '/settings/icon-groupinfo', form);
},
resetSettings: () => req('POST', '/settings/reset'),
// Push notifications
getPushKey: () => req('GET', '/push/vapid-public'),
subscribePush: (sub) => req('POST', '/push/subscribe', sub),
unsubscribePush: (endpoint) => req('POST', '/push/unsubscribe', { endpoint }),
// Link preview
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
// VAPID key management (admin only)
generateVapidKeys: () => req('POST', '/push/generate-vapid'),
};

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'socket': ['socket.io-client'],
'emoji': ['emoji-mart', '@emoji-mart/data', '@emoji-mart/react'],
}
}
}
},
server: {
proxy: {
'/api': 'http://localhost:3000',
'/uploads': 'http://localhost:3000',
'/socket.io': { target: 'http://localhost:3000', ws: true }
}
}
});