This commit is contained in:
2026-03-09 14:36:19 -04:00
parent f37fe0086f
commit 42ad779750
40 changed files with 1928 additions and 593 deletions

View File

@@ -1,15 +1,22 @@
# jama Configuration # jama Configuration
# just another messaging app # just another messaging app
# Timezone — must match your host timezone (e.g. America/Toronto, Europe/London, Asia/Tokyo)
# Run 'timedatectl' on your host to find the correct value
TZ=UTC
# Copy this file to .env and customize # Copy this file to .env and customize
# Image version to run (set by build.sh, or use 'latest') # Image version to run (set by build.sh, or use 'latest')
JAMA_VERSION=latest JAMA_VERSION=0.3.0
# Default admin credentials (used on FIRST RUN only) # Default admin credentials (used on FIRST RUN only)
ADMIN_NAME=Admin User ADMIN_NAME=Admin User
ADMIN_EMAIL=admin@jama.local ADMIN_EMAIL=admin@jama.local
ADMIN_PASS=Admin@1234 ADMIN_PASS=Admin@1234
# Default password for bulk-imported users (when no password is set in CSV)
USER_PASS=user@1234
# Set to true to reset admin password to ADMIN_PASS on every restart # Set to true to reset admin password to ADMIN_PASS on every restart
# WARNING: Leave false in production - shows a warning on login page when true # WARNING: Leave false in production - shows a warning on login page when true
PW_RESET=false PW_RESET=false

View File

@@ -24,15 +24,18 @@ LABEL org.opencontainers.image.title="jama" \
org.opencontainers.image.created="${BUILD_DATE}" \ org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.source="https://github.com/yourorg/jama" org.opencontainers.image.source="https://github.com/yourorg/jama"
ENV TEAMCHAT_VERSION=${VERSION} ENV JAMA_VERSION=${VERSION}
RUN apk add --no-cache sqlite RUN apk add --no-cache sqlite python3 make g++
WORKDIR /app WORKDIR /app
COPY backend/package*.json ./ COPY backend/package*.json ./
RUN npm install --omit=dev RUN npm install --omit=dev
# Remove build tools after compile to keep image lean
RUN apk del python3 make g++
COPY backend/ ./ COPY backend/ ./
COPY --from=builder /app/frontend/dist ./public COPY --from=builder /app/frontend/dist ./public

115
README.md
View File

@@ -7,70 +7,77 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
## Features ## Features
- 🔐 **Authentication** — Login, remember me, forced password change on first login ### Messaging
- 💬 **Real-time messaging** — WebSocket (Socket.io) powered chat - **Real-time messaging** — WebSocket-powered (Socket.io); messages appear instantly across all clients
- 👥 **Public channels** — Admin-created, all users auto-joined - **Image attachments** — Attach and send images; auto-compressed client-side before upload
- 🔒 **Private groups**User-created, owner-managed - **Message replies** — Quote and reply to any message with an inline preview
- 📷 **Image uploads**Attach images to messages - **Emoji reactions** — Quick-react with common emojis or open the full emoji picker; one reaction per user, replaceable
- 💬 **Message quoting**Reply to any message with preview - **@Mentions** — Type `@` to search and tag users with autocomplete; mentioned users receive a notification
- 😎 **Emoji reactions**Quick reactions + full emoji picker - **Link previews** — URLs are automatically expanded with Open Graph metadata (title, image, site name)
- @**Mentions** — @mention users with autocomplete, they get notified - **Typing indicators** — See when others are composing a message
- 🔗 **Link previews**Auto-fetches OG metadata for URLs - **Image lightbox** — Tap any image to open it full-screen with pinch-to-zoom support
- 📱 **PWA** — Install to home screen, works offline
- 👤 **Profiles** — Custom avatars, display names, about me ### Channels & Groups
- ⚙️ **Admin settings** — Custom logo, app name - **Public channels** — Admin-created; all users are automatically added
- 👨‍💼 **User management**Create, suspend, delete, bulk CSV import - **Private groups / DMs** — Any user can create; membership is invite-only by the owner
- 📢 **Read-only channels** — Announcement-style public channels - **Read-only channels** — Admin-configurable announcement-style channels; only admins can post
- **Support group** — A private admin-only group that receives submissions from the login page contact form
### Users & Profiles
- **Authentication** — Email/password login with optional Remember Me (30-day session)
- **Forced password change** — New users must change their password on first login
- **User profiles** — Custom display name, avatar upload, About Me text
- **Profile popup** — Click any user's avatar in chat to view their profile card
- **Admin badge** — Admins display a role badge; can be hidden per-user in Profile settings
### Notifications
- **In-app notifications** — Mention alerts with toast notifications
- **Unread indicators** — Private groups with new unread messages are highlighted and bolded in the sidebar
- **Web Push notifications** — Badge and push notifications for mentions and new private messages when the app is backgrounded or closed (requires HTTPS)
### Admin & Settings
- **User Manager** — Create, suspend, activate, delete users; reset passwords; change roles
- **Bulk CSV import** — Import multiple users at once from a CSV file
- **App branding** — Customize app name, logo, New Chat icon, and Group Info icon via the Settings panel
- **Reset to defaults** — One-click reset of all branding customizations
- **Version display** — Current app version shown in the Settings panel
### PWA
- **Installable** — Install to home screen on mobile and desktop via the browser install prompt
- **Dynamic app icon** — Uploaded logo is automatically resized to 192×192 and 512×512 and used as the PWA shortcut icon
- **Dynamic manifest** — App name and icons in the PWA manifest update live when changed in Settings
- **Offline fallback** — Basic offline support via service worker caching
### Contact Form
- **Login page contact form** — A "Contact Support" button on the login page opens a form (name, email, message, math captcha) that posts directly into the admin Support group
--- ---
## Quick Start ## Tech Stack
### Prerequisites | Layer | Technology |
- Docker & Docker Compose |---|---|
| Backend | Node.js, Express, Socket.io |
### 1. Build a versioned image | Database | SQLite (better-sqlite3) |
| Frontend | React 18, Vite |
```bash | Image processing | sharp |
# Build and tag as v1.0.0 (also tags :latest) | Push notifications | web-push (VAPID) |
./build.sh 1.0.0 | Containerization | Docker, Docker Compose |
| Reverse proxy / SSL | Caddy (recommended) |
# Build latest only
./build.sh
```
### 2. Deploy with Docker Compose
```bash
cp .env.example .env
# Edit .env — set TEAMCHAT_VERSION, admin credentials, JWT_SECRET
nano .env
docker compose up -d
# View logs
docker compose logs -f
```
App will be available at **http://localhost:3000**
--- ---
## Release Workflow ## Requirements
TeamChat uses a **build-then-run** pattern. You build the image once on your build machine (or CI), then the compose file just runs the pre-built image — no build step at deploy time. - **Docker** and **Docker Compose v2**
- A domain name with DNS pointed at your server (required for HTTPS and Web Push notifications)
- Ports **80** and **443** open on your server firewall (if using Caddy for SSL)
``` ---
┌─────────────────────┐ ┌──────────────────────────┐
│ Build machine / CI │ │ Server / Portainer │
│ │ │ │
│ ./build.sh 1.2.0 │─────▶│ TEAMCHAT_VERSION=1.2.0 │
│ (or push to │ │ docker compose up -d │
│ registry first) │ │ │
└─────────────────────┘ └──────────────────────────┘
```
### Build script usage ## Building the Image
All builds use `build.sh`. No host Node.js installation is required — `npm install` and the Vite build run inside Docker.
```bash ```bash
# Build and tag as :latest only # Build and tag as :latest only

7
about.json.example Normal file
View File

@@ -0,0 +1,7 @@
{
"built_with": "Node.js · Express · Socket.io · SQLite · React · Vite · Claude.ai",
"developer": "Your Name or Organization",
"license": "AGPL 3.0",
"license_url": "https://www.gnu.org/licenses/agpl-3.0.html",
"description": "Self-hosted, privacy-first team messaging."
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "teamchat-backend", "name": "jama-backend",
"version": "1.0.0", "version": "0.3.0",
"description": "TeamChat backend server", "description": "TeamChat backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -32,9 +32,10 @@ app.use('/uploads', express.static('/app/uploads'));
// API Routes // API Routes
app.use('/api/auth', require('./routes/auth')); app.use('/api/auth', require('./routes/auth'));
app.use('/api/users', require('./routes/users')); app.use('/api/users', require('./routes/users'));
app.use('/api/groups', require('./routes/groups')); app.use('/api/groups', require('./routes/groups')(io));
app.use('/api/messages', require('./routes/messages')); app.use('/api/messages', require('./routes/messages'));
app.use('/api/settings', require('./routes/settings')); app.use('/api/settings', require('./routes/settings'));
app.use('/api/about', require('./routes/about'));
app.use('/api/push', pushRouter); app.use('/api/push', pushRouter);
// Link preview proxy // Link preview proxy
@@ -128,6 +129,9 @@ io.on('connection', (socket) => {
// Broadcast online status // Broadcast online status
io.emit('user:online', { userId }); io.emit('user:online', { userId });
// Join personal room for direct notifications
socket.join(`user:${userId}`);
// Join rooms for all user's groups // Join rooms for all user's groups
const db = getDb(); const db = getDb();
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all(); const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
@@ -136,6 +140,16 @@ io.on('connection', (socket) => {
const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId); const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId);
for (const g of privateGroups) socket.join(`group:${g.group_id}`); for (const g of privateGroups) socket.join(`group:${g.group_id}`);
// When a new group is created and pushed to this socket, join its room
socket.on('group:join-room', ({ groupId }) => {
socket.join(`group:${groupId}`);
});
// When a user leaves a group, remove them from the socket room
socket.on('group:leave-room', ({ groupId }) => {
socket.leave(`group:${groupId}`);
});
// Handle new message // Handle new message
socket.on('message:send', async (data) => { socket.on('message:send', async (data) => {
const { groupId, content, replyToId, imageUrl, linkPreview } = data; const { groupId, content, replyToId, imageUrl, linkPreview } = data;
@@ -271,12 +285,31 @@ io.on('connection', (socket) => {
socket.on('message:delete', (data) => { socket.on('message:delete', (data) => {
const { messageId } = data; const { messageId } = data;
const db = getDb(); const db = getDb();
const message = db.prepare('SELECT m.*, g.type as group_type, g.owner_id as group_owner_id FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?').get(messageId); const message = db.prepare(`
SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_direct
FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?
`).get(messageId);
if (!message) return; if (!message) return;
const canDelete = message.user_id === userId || const isAdmin = socket.user.role === 'admin';
(socket.user.role === 'admin' && message.group_type === 'public') || const isOwner = message.group_owner_id === userId;
(message.group_type === 'private' && message.group_owner_id === userId); const isAuthor = message.user_id === userId;
// Rules:
// 1. Author can always delete their own message
// 2. Admin can delete in any public group or any group they're a member of
// 3. Group owner can delete any message in their group
// 4. In direct messages: author + owner rules apply (no blanket block)
let canDelete = isAuthor || isOwner;
if (!canDelete && isAdmin) {
if (message.group_type === 'public') {
canDelete = true;
} else {
// Admin can delete in private/direct groups they're a member of
const membership = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(message.group_id, userId);
if (membership) canDelete = true;
}
}
if (!canDelete) return; if (!canDelete) return;

View File

@@ -166,6 +166,22 @@ function initDb() {
} }
} catch (e) { console.error('[DB] active_sessions migration error:', e.message); } } catch (e) { console.error('[DB] active_sessions migration error:', e.message); }
// Migration: add is_direct for user-to-user direct messages
try {
db.exec("ALTER TABLE groups ADD COLUMN is_direct INTEGER NOT NULL DEFAULT 0");
console.log('[DB] Migration: added is_direct column');
} catch (e) { /* column already exists */ }
// Migration: store both peer IDs so direct-message names survive member leave
try {
db.exec("ALTER TABLE groups ADD COLUMN direct_peer1_id INTEGER");
console.log('[DB] Migration: added direct_peer1_id column');
} catch (e) { /* column already exists */ }
try {
db.exec("ALTER TABLE groups ADD COLUMN direct_peer2_id INTEGER");
console.log('[DB] Migration: added direct_peer2_id column');
} catch (e) { /* column already exists */ }
console.log('[DB] Schema initialized'); console.log('[DB] Schema initialized');
return db; return db;
} }

View File

@@ -0,0 +1,40 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const ABOUT_FILE = '/app/data/about.json';
const DEFAULTS = {
built_with: 'Node.js · Express · Socket.io · SQLite · React · Vite · Claude.ai',
developer: 'Ricky Stretch',
license: 'AGPL 3.0',
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',
description: 'Self-hosted, privacy-first team messaging.',
};
// GET /api/about — public, no auth required
router.get('/', (req, res) => {
let overrides = {};
try {
if (fs.existsSync(ABOUT_FILE)) {
const raw = fs.readFileSync(ABOUT_FILE, 'utf8');
overrides = JSON.parse(raw);
}
} catch (e) {
console.warn('about.json parse error:', e.message);
}
// Version always comes from the runtime env (same source as Settings window)
const about = {
...DEFAULTS,
...overrides,
version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
};
// Never expose docker_image — removed from UI
delete about.docker_image;
res.json({ about });
});
module.exports = router;

View File

@@ -3,12 +3,50 @@ const router = express.Router();
const { getDb } = require('../models/db'); const { getDb } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth'); const { authMiddleware, adminMiddleware } = require('../middleware/auth');
// Helper: emit group:new to all members of a group
function emitGroupNew(io, groupId) {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return;
if (group.type === 'public') {
io.emit('group:new', { group });
} else {
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
for (const m of members) {
io.to(`user:${m.user_id}`).emit('group:new', { group });
}
}
}
// Helper: emit group:deleted to all members
function emitGroupDeleted(io, groupId, members) {
for (const uid of members) {
io.to(`user:${uid}`).emit('group:deleted', { groupId });
}
}
// Helper: emit group:updated to all members
function emitGroupUpdated(io, groupId) {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return;
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
const uids = group.type === 'public'
? db.prepare("SELECT id as user_id FROM users WHERE status = 'active'").all()
: members;
for (const m of uids) {
io.to(`user:${m.user_id}`).emit('group:updated', { group });
}
}
// Inject io into routes
module.exports = (io) => {
// Get all groups for current user // Get all groups for current user
router.get('/', authMiddleware, (req, res) => { router.get('/', authMiddleware, (req, res) => {
const db = getDb(); const db = getDb();
const userId = req.user.id; const userId = req.user.id;
// Public groups (all users are members)
const publicGroups = db.prepare(` const publicGroups = db.prepare(`
SELECT g.*, SELECT g.*,
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count, (SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
@@ -19,8 +57,8 @@ router.get('/', authMiddleware, (req, res) => {
ORDER BY g.is_default DESC, g.name ASC ORDER BY g.is_default DESC, g.name ASC
`).all(); `).all();
// Private groups (user is a member) // For direct messages, replace name with opposite user's display name
const privateGroups = db.prepare(` const privateGroupsRaw = db.prepare(`
SELECT g.*, SELECT g.*,
u.name as owner_name, u.name as owner_name,
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count, (SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
@@ -33,34 +71,98 @@ router.get('/', authMiddleware, (req, res) => {
ORDER BY last_message_at DESC NULLS LAST ORDER BY last_message_at DESC NULLS LAST
`).all(userId); `).all(userId);
// For direct groups, set the name to the other user's display name
// Uses direct_peer1_id / direct_peer2_id so the name survives after a user leaves
const privateGroups = privateGroupsRaw.map(g => {
if (g.is_direct) {
// Backfill peer IDs for groups created before this migration
if (!g.direct_peer1_id || !g.direct_peer2_id) {
const peers = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 2').all(g.id);
if (peers.length === 2) {
db.prepare('UPDATE groups SET direct_peer1_id = ?, direct_peer2_id = ? WHERE id = ?')
.run(peers[0].user_id, peers[1].user_id, g.id);
g.direct_peer1_id = peers[0].user_id;
g.direct_peer2_id = peers[1].user_id;
}
}
const otherUserId = g.direct_peer1_id === userId ? g.direct_peer2_id : g.direct_peer1_id;
if (otherUserId) {
const other = db.prepare('SELECT display_name, name FROM users WHERE id = ?').get(otherUserId);
if (other) g.name = other.display_name || other.name;
}
}
return g;
});
res.json({ publicGroups, privateGroups }); res.json({ publicGroups, privateGroups });
}); });
// Create group // Create group
router.post('/', authMiddleware, (req, res) => { router.post('/', authMiddleware, (req, res) => {
const { name, type, memberIds, isReadonly } = req.body; const { name, type, memberIds, isReadonly, isDirect } = req.body;
const db = getDb(); const db = getDb();
if (type === 'public' && req.user.role !== 'admin') { if (type === 'public' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can create public groups' }); return res.status(403).json({ error: 'Only admins can create public groups' });
} }
// Direct message: find or create
if (isDirect && memberIds && memberIds.length === 1) {
const otherUserId = memberIds[0];
const userId = req.user.id;
// Check if a direct group already exists between these two users
const existing = db.prepare(`
SELECT g.id FROM groups g
JOIN group_members gm1 ON gm1.group_id = g.id AND gm1.user_id = ?
JOIN group_members gm2 ON gm2.group_id = g.id AND gm2.user_id = ?
WHERE g.is_direct = 1
LIMIT 1
`).get(userId, otherUserId);
if (existing) {
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id);
// Ensure current user is still a member (may have left)
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(existing.id, userId);
// Re-set readonly to false so both can post again
db.prepare("UPDATE groups SET is_readonly = 0, owner_id = NULL, updated_at = datetime('now') WHERE id = ?").run(existing.id);
return res.json({ group: db.prepare('SELECT * FROM groups WHERE id = ?').get(existing.id) });
}
// Get other user's display name for the group name (stored internally, overridden per-user on fetch)
const otherUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(otherUserId);
const dmName = (otherUser?.display_name || otherUser?.name) + ' ↔ ' + (req.user.display_name || req.user.name);
const result = db.prepare(` const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_readonly) INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, direct_peer1_id, direct_peer2_id)
VALUES (?, ?, ?, ?) VALUES (?, 'private', NULL, 0, 1, ?, ?)
`).run(dmName, userId, otherUserId);
const groupId = result.lastInsertRowid;
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, userId);
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, otherUserId);
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
// Notify both users via socket
emitGroupNew(io, groupId);
return res.json({ group });
}
const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct)
VALUES (?, ?, ?, ?, 0)
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0); `).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
const groupId = result.lastInsertRowid; const groupId = result.lastInsertRowid;
if (type === 'public') { if (type === 'public') {
// Add all users to public group
const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all(); const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)'); const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const u of allUsers) insert.run(groupId, u.id); for (const u of allUsers) insert.run(groupId, u.id);
} else { } else {
// Add creator
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id); db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id);
// Add other members
if (memberIds && memberIds.length > 0) { if (memberIds && memberIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)'); const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const uid of memberIds) insert.run(groupId, uid); for (const uid of memberIds) insert.run(groupId, uid);
@@ -68,6 +170,10 @@ router.post('/', authMiddleware, (req, res) => {
} }
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
// Notify all members via socket
emitGroupNew(io, groupId);
res.json({ group }); res.json({ group });
}); });
@@ -77,14 +183,14 @@ router.patch('/:id/rename', authMiddleware, (req, res) => {
const db = getDb(); const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' }); if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' }); if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
if (group.is_direct) return res.status(403).json({ error: 'Cannot rename a direct message' });
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' }); if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') { if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner can rename private group' }); return res.status(403).json({ error: 'Only owner can rename private group' });
} }
db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id); db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id);
emitGroupUpdated(io, group.id);
res.json({ success: true }); res.json({ success: true });
}); });
@@ -108,15 +214,37 @@ router.post('/:id/members', authMiddleware, (req, res) => {
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' }); if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' }); if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
if (group.is_direct) return res.status(400).json({ error: 'Cannot add members to a direct message' });
if (group.owner_id !== req.user.id && req.user.role !== 'admin') { if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner can add members' }); return res.status(403).json({ error: 'Only owner can add members' });
} }
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId); db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId);
// Post a system message so all members see who was added
const addedUser = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
const sysResult = db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'system')
`).run(group.id, userId, `${addedName} has joined the conversation.`);
const sysMsg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(sysResult.lastInsertRowid);
sysMsg.reactions = [];
io.to(`group:${group.id}`).emit('message:new', sysMsg);
// Join all of the added user's active sockets to the group room server-side,
// so they receive messages immediately without needing a client round-trip
io.in(`user:${userId}`).socketsJoin(`group:${group.id}`);
// Notify the added user in real-time so their sidebar updates without a refresh
io.to(`user:${userId}`).emit('group:new', { group });
res.json({ success: true }); res.json({ success: true });
}); });
// Remove a member from a private group (owner or admin only) // Remove a member from a private group
router.delete('/:id/members/:userId', authMiddleware, (req, res) => { router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
const db = getDb(); const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
@@ -126,9 +254,7 @@ router.delete('/:id/members/:userId', authMiddleware, (req, res) => {
return res.status(403).json({ error: 'Only owner or admin can remove members' }); return res.status(403).json({ error: 'Only owner or admin can remove members' });
} }
const targetId = parseInt(req.params.userId); const targetId = parseInt(req.params.userId);
if (targetId === group.owner_id) { if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' });
return res.status(400).json({ error: 'Cannot remove the group owner' });
}
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId); db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId);
res.json({ success: true }); res.json({ success: true });
}); });
@@ -140,11 +266,43 @@ router.delete('/:id/leave', authMiddleware, (req, res) => {
if (!group) return res.status(404).json({ error: 'Group not found' }); if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' }); if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, req.user.id); const userId = req.user.id;
const leaverName = req.user.display_name || req.user.name;
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, userId);
// Post a system message so remaining members see the leave notice
const sysResult = db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'system')
`).run(group.id, userId, `${leaverName} has left the conversation.`);
const sysMsg = db.prepare(`
SELECT m.*, u.name as user_name, u.display_name as user_display_name,
u.avatar as user_avatar, u.role as user_role, u.status as user_status,
u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
`).get(sysResult.lastInsertRowid);
sysMsg.reactions = [];
// Broadcast to remaining members in the group room
io.to(`group:${group.id}`).emit('message:new', sysMsg);
if (group.is_direct) {
// Make remaining user owner so they can still manage the conversation
const remaining = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? LIMIT 1').get(group.id);
if (remaining) {
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?")
.run(remaining.user_id, group.id);
}
// Tell the leaver's socket to leave the group room and remove from sidebar
io.to(`user:${userId}`).emit('group:deleted', { groupId: group.id });
}
res.json({ success: true }); res.json({ success: true });
}); });
// Admin take ownership of private group // Admin take ownership
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => { router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb(); const db = getDb();
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id); db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id);
@@ -152,7 +310,7 @@ router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) =
res.json({ success: true }); res.json({ success: true });
}); });
// Delete group (admin or private group owner) // Delete group
router.delete('/:id', authMiddleware, (req, res) => { router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb(); const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
@@ -163,8 +321,21 @@ router.delete('/:id', authMiddleware, (req, res) => {
return res.status(403).json({ error: 'Only owner or admin can delete private groups' }); return res.status(403).json({ error: 'Only owner or admin can delete private groups' });
} }
// Collect members before deleting
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(group.id).map(m => m.user_id);
// Add all active users for public groups
if (group.type === 'public') {
const all = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
all.forEach(u => { if (!members.includes(u.id)) members.push(u.id); });
}
db.prepare('DELETE FROM groups WHERE id = ?').run(group.id); db.prepare('DELETE FROM groups WHERE id = ?').run(group.id);
// Notify all affected users
emitGroupDeleted(io, group.id, members);
res.json({ success: true }); res.json({ success: true });
}); });
module.exports = router; return router;
};

View File

@@ -39,7 +39,8 @@ router.get('/', (req, res) => {
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get(); const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
if (admin) obj.admin_email = admin.email; if (admin) obj.admin_email = admin.email;
// Expose app version from Docker build arg env var // Expose app version from Docker build arg env var
obj.app_version = process.env.TEAMCHAT_VERSION || 'dev'; obj.app_version = process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234';
res.json({ settings: obj }); res.json({ settings: obj });
}); });

View File

@@ -2,7 +2,6 @@ const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const fs = require('fs');
const router = express.Router(); const router = express.Router();
const { getDb, addUserToPublicGroups } = require('../models/db'); const { getDb, addUserToPublicGroups } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth'); const { authMiddleware, adminMiddleware } = require('../middleware/auth');
@@ -23,6 +22,29 @@ const uploadAvatar = multer({
} }
}); });
// Resolve unique name: "John Doe" exists → return "John Doe (1)", then "(2)" etc.
function resolveUniqueName(db, baseName, excludeId = null) {
const existing = db.prepare(
"SELECT name FROM users WHERE status != 'deleted' AND id != ? AND (name = ? OR name LIKE ?)"
).all(excludeId ?? -1, baseName, `${baseName} (%)`);
if (existing.length === 0) return baseName;
let max = 0;
for (const u of existing) {
const m = u.name.match(/\((\d+)\)$/);
if (m) max = Math.max(max, parseInt(m[1]));
else max = Math.max(max, 0);
}
return `${baseName} (${max + 1})`;
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function getDefaultPassword(db) {
return process.env.USER_PASS || 'user@1234';
}
// List users (admin) // List users (admin)
router.get('/', authMiddleware, adminMiddleware, (req, res) => { router.get('/', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb(); const db = getDb();
@@ -34,7 +56,7 @@ router.get('/', authMiddleware, adminMiddleware, (req, res) => {
res.json({ users }); res.json({ users });
}); });
// Get single user profile (public-ish for mentions) // Search users (public-ish for mentions/add-member)
router.get('/search', authMiddleware, (req, res) => { router.get('/search', authMiddleware, (req, res) => {
const { q } = req.query; const { q } = req.query;
const db = getDb(); const db = getDb();
@@ -46,63 +68,90 @@ router.get('/search', authMiddleware, (req, res) => {
res.json({ users }); res.json({ users });
}); });
// Create user (admin) // Check if a display name is already taken (excludes self)
router.get('/check-display-name', authMiddleware, (req, res) => {
const { name } = req.query;
if (!name) return res.json({ taken: false });
const db = getDb();
const conflict = db.prepare(
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
).get(name, req.user.id);
res.json({ taken: !!conflict });
});
// Create user (admin) — req 3: skip duplicate email, req 4: suffix duplicate names
router.post('/', authMiddleware, adminMiddleware, (req, res) => { router.post('/', authMiddleware, adminMiddleware, (req, res) => {
const { name, email, password, role } = req.body; const { name, email, password, role } = req.body;
if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, password required' }); if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
const db = getDb(); const db = getDb();
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email); const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (exists) return res.status(400).json({ error: 'Email already in use' }); if (exists) return res.status(400).json({ error: 'Email already in use' });
const hash = bcrypt.hashSync(password, 10); const resolvedName = resolveUniqueName(db, name.trim());
const pw = (password || '').trim() || getDefaultPassword(db);
const hash = bcrypt.hashSync(pw, 10);
const result = db.prepare(` const result = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password) INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1) VALUES (?, ?, ?, ?, 'active', 1)
`).run(name, email, hash, role === 'admin' ? 'admin' : 'member'); `).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(result.lastInsertRowid); addUserToPublicGroups(result.lastInsertRowid);
const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid); const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
res.json({ user }); res.json({ user });
}); });
// Bulk create users via CSV data // Bulk create users
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => { router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
const { users } = req.body; // array of {name, email, password, role} const { users } = req.body;
const db = getDb(); const db = getDb();
const results = { created: [], errors: [] }; const results = { created: [], skipped: [] };
const seenEmails = new Set();
const defaultPw = getDefaultPassword(db);
const insertUser = db.prepare(` const insertUser = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password) INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1) VALUES (?, ?, ?, ?, 'active', 1)
`); `);
const transaction = db.transaction((users) => {
for (const u of users) { for (const u of users) {
if (!u.name || !u.email || !u.password) { const email = (u.email || '').trim().toLowerCase();
results.errors.push({ email: u.email, error: 'Missing required fields' }); const name = (u.name || '').trim();
continue; if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; }
} if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(u.email); if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
if (exists) { seenEmails.add(email);
results.errors.push({ email: u.email, error: 'Email already exists' }); const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
continue; if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
}
try { try {
const hash = bcrypt.hashSync(u.password, 10); const resolvedName = resolveUniqueName(db, name);
const r = insertUser.run(u.name, u.email, hash, u.role === 'admin' ? 'admin' : 'member'); const pw = (u.password || '').trim() || defaultPw;
const hash = bcrypt.hashSync(pw, 10);
const r = insertUser.run(resolvedName, email, hash, u.role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(r.lastInsertRowid); addUserToPublicGroups(r.lastInsertRowid);
results.created.push(u.email); results.created.push(email);
} catch (e) { } catch (e) {
results.errors.push({ email: u.email, error: e.message }); results.skipped.push({ email, reason: e.message });
} }
} }
});
transaction(users);
res.json(results); res.json(results);
}); });
// Update user name (admin only — req 5)
router.patch('/:id/name', authMiddleware, adminMiddleware, (req, res) => {
const { name } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
// Pass the target's own id so their current name is excluded from the duplicate check
const resolvedName = resolveUniqueName(db, name.trim(), req.params.id);
db.prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?").run(resolvedName, target.id);
res.json({ success: true, name: resolvedName });
});
// Update user role (admin) // Update user role (admin)
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => { router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
const { role } = req.body; const { role } = req.body;
@@ -111,7 +160,6 @@ router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
if (!target) return res.status(404).json({ error: 'User not found' }); if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' }); if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id); db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
res.json({ success: true }); res.json({ success: true });
}); });
@@ -132,7 +180,6 @@ router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' }); if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' }); if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id); db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true }); res.json({ success: true });
}); });
@@ -150,28 +197,80 @@ router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' }); if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' }); if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id); db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true }); res.json({ success: true });
}); });
// Update own profile // Update own profile — display name must be unique (req 6)
router.patch('/me/profile', authMiddleware, (req, res) => { router.patch('/me/profile', authMiddleware, (req, res) => {
const { displayName, aboutMe, hideAdminTag } = req.body; const { displayName, aboutMe, hideAdminTag } = req.body;
const db = getDb(); const db = getDb();
if (displayName) {
const conflict = db.prepare(
"SELECT id FROM users WHERE LOWER(display_name) = LOWER(?) AND id != ? AND status != 'deleted'"
).get(displayName, req.user.id);
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
}
db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, updated_at = datetime('now') WHERE id = ?") db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, updated_at = datetime('now') WHERE id = ?")
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, req.user.id); .run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, req.user.id);
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag FROM users WHERE id = ?').get(req.user.id);
res.json({ user }); res.json({ user });
}); });
// Upload avatar // Upload avatar — resize if needed, skip compression for files under 500 KB
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), (req, res) => { router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const sharp = require('sharp');
const filePath = req.file.path;
const fileSizeBytes = req.file.size;
const FIVE_HUNDRED_KB = 500 * 1024;
const MAX_DIM = 256; // max width/height in pixels
const image = sharp(filePath);
const meta = await image.metadata();
const needsResize = (meta.width > MAX_DIM || meta.height > MAX_DIM);
if (fileSizeBytes < FIVE_HUNDRED_KB && !needsResize) {
// Small enough and already correctly sized — serve as-is
} else {
// Resize (and compress only if over 500 KB)
const outPath = filePath.replace(/(\.[^.]+)$/, '_p$1');
let pipeline = sharp(filePath).resize(MAX_DIM, MAX_DIM, { fit: 'cover', withoutEnlargement: true });
if (fileSizeBytes >= FIVE_HUNDRED_KB) {
// Compress: use webp for best size/quality ratio
pipeline = pipeline.webp({ quality: 82 });
await pipeline.toFile(outPath + '.webp');
const fs = require('fs');
fs.unlinkSync(filePath);
fs.renameSync(outPath + '.webp', filePath.replace(/\.[^.]+$/, '.webp'));
const newPath = filePath.replace(/\.[^.]+$/, '.webp');
const newFilename = path.basename(newPath);
const db = getDb();
const avatarUrl = `/uploads/avatars/${newFilename}`;
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
return res.json({ avatarUrl });
} else {
// Under 500 KB but needs resize — resize only, keep original format
await pipeline.toFile(outPath);
const fs = require('fs');
fs.unlinkSync(filePath);
fs.renameSync(outPath, filePath);
}
}
const avatarUrl = `/uploads/avatars/${req.file.filename}`; const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const db = getDb(); const db = getDb();
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id); db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
res.json({ avatarUrl }); res.json({ avatarUrl });
} catch (err) {
console.error('Avatar processing error:', err);
// Fall back to serving unprocessed file
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const db = getDb();
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
res.json({ avatarUrl });
}
}); });
module.exports = router; module.exports = router;

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-latest}" VERSION="${1:-0.3.0}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama" IMAGE_NAME="jama"

View File

@@ -9,9 +9,11 @@ services:
- "${PORT:-3000}:3000" - "${PORT:-3000}:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- TZ=${TZ:-UTC}
- ADMIN_NAME=${ADMIN_NAME:-Admin User} - ADMIN_NAME=${ADMIN_NAME:-Admin User}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234} - ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- USER_PASS=${USER_PASS:-user@1234}
- PW_RESET=${PW_RESET:-false} - PW_RESET=${PW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024} - JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
- APP_NAME=${APP_NAME:-jama} - APP_NAME=${APP_NAME:-jama}

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icons/jama.png" /> <link rel="icon" type="image/png" href="/icons/jama.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#1a73e8" /> <meta name="theme-color" content="#1a73e8" />
<meta name="description" content="jama - just another messaging app" /> <meta name="description" content="jama - just another messaging app" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />

View File

@@ -1,6 +1,6 @@
{ {
"name": "teamchat-frontend", "name": "jama-frontend",
"version": "1.0.0", "version": "0.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -5,7 +5,7 @@
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "portrait-primary", "orientation": "any",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#1a73e8", "theme_color": "#1a73e8",
"icons": [ "icons": [
@@ -33,5 +33,6 @@
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
} }
] ],
"min_width": "320px"
} }

View File

@@ -0,0 +1,87 @@
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 [settings, setSettings] = useState({ app_name: 'jama', app_version: '' });
const [about, setAbout] = useState(null);
useEffect(() => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
fetch('/api/about')
.then(r => r.json())
.then(({ about }) => setAbout(about))
.catch(() => {});
}, []);
const appName = settings.app_name || 'jama';
// Version always mirrors Settings window — from settings API (env var)
const version = settings.app_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="/icons/jama.png" alt="jama" 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

@@ -5,6 +5,8 @@
background: var(--surface-variant); background: var(--surface-variant);
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
min-height: 0;
height: 100%;
} }
.chat-window.empty { .chat-window.empty {
@@ -79,11 +81,16 @@
/* Messages */ /* Messages */
.messages-container { .messages-container {
flex: 1; flex: 1;
min-height: 0; /* critical: allows flex child to shrink below content size */
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
/* Anchor scroll to bottom so new messages appear above the input */
scroll-padding-bottom: 0;
overscroll-behavior: contain;
} }
.load-more-btn { .load-more-btn {

View File

@@ -8,7 +8,7 @@ import MessageInput from './MessageInput.jsx';
import GroupInfoModal from './GroupInfoModal.jsx'; import GroupInfoModal from './GroupInfoModal.jsx';
import './ChatWindow.css'; import './ChatWindow.css';
export default function ChatWindow({ group, onBack, onGroupUpdated }) { export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage }) {
const { socket } = useSocket(); const { socket } = useSocket();
const { user } = useAuth(); const { user } = useAuth();
const toast = useToast(); const toast = useToast();
@@ -23,6 +23,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const messagesTopRef = useRef(null); const messagesTopRef = useRef(null);
const typingTimers = useRef({}); const typingTimers = useRef({});
const swipeStartX = useRef(null);
const swipeStartY = useRef(null);
useEffect(() => { useEffect(() => {
api.getSettings().then(({ settings }) => { api.getSettings().then(({ settings }) => {
@@ -33,6 +35,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
return () => window.removeEventListener('jama:settings-changed', handler); return () => window.removeEventListener('jama:settings-changed', handler);
}, []); }, []);
const scrollToBottom = useCallback((smooth = false) => { const scrollToBottom = useCallback((smooth = false) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, []); }, []);
@@ -110,7 +113,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
setHasMore(older.length >= 50); setHasMore(older.length >= 50);
}; };
const handleSend = async ({ content, imageFile, linkPreview }) => { const handleSend = async ({ content, imageFile, linkPreview, emojiOnly }) => {
if (!group) return; if (!group) return;
const replyId = replyTo?.id; const replyId = replyTo?.id;
setReplyTo(null); setReplyTo(null);
@@ -125,7 +128,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
} }
} else { } else {
socket?.emit('message:send', { socket?.emit('message:send', {
groupId: group.id, content, replyToId: replyId, linkPreview groupId: group.id, content, replyToId: replyId, linkPreview, emojiOnly
}); });
} }
} catch (e) { } catch (e) {
@@ -149,8 +152,30 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
); );
} }
const handleTouchStart = (e) => {
swipeStartX.current = e.touches[0].clientX;
swipeStartY.current = e.touches[0].clientY;
};
const handleTouchEnd = (e) => {
if (swipeStartX.current === null || !onBack) return;
const dx = e.changedTouches[0].clientX - swipeStartX.current;
const dy = Math.abs(e.changedTouches[0].clientY - swipeStartY.current);
// Swipe right: at least 80px horizontal, less than 60px vertical drift
if (dx > 80 && dy < 60) {
e.preventDefault();
onBack();
}
swipeStartX.current = null;
swipeStartY.current = null;
};
return ( return (
<div className="chat-window"> <div
className="chat-window"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Header */} {/* Header */}
<div className="chat-header"> <div className="chat-header">
{onBack && ( {onBack && (
@@ -172,12 +197,12 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
) : null} ) : null}
</div> </div>
<span className="chat-header-sub"> <span className="chat-header-sub">
{group.type === 'public' ? 'Public group' : 'Private group'} {group.is_direct ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
</span> </span>
</div> </div>
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info"> <button className="btn-icon" onClick={() => setShowInfo(true)} title="Message info">
{iconGroupInfo ? ( {iconGroupInfo ? (
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} /> <img src={iconGroupInfo} alt="Message info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
) : ( ) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="24" height="24"> <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="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" /> <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" />
@@ -203,9 +228,11 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
message={msg} message={msg}
prevMessage={messages[i - 1]} prevMessage={messages[i - 1]}
currentUser={user} currentUser={user}
isDirect={!!group.is_direct}
onReply={(m) => setReplyTo(m)} onReply={(m) => setReplyTo(m)}
onDelete={(id) => socket?.emit('message:delete', { messageId: id })} onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })} onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
onDirectMessage={onDirectMessage}
/> />
))} ))}
{typing.length > 0 && ( {typing.length > 0 && (
@@ -236,7 +263,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
) : ( ) : (
<div className="readonly-bar"> <div className="readonly-bar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
This channel is read-only This message is read-only
</div> </div>
)} )}
@@ -245,6 +272,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
group={group} group={group}
onClose={() => setShowInfo(false)} onClose={() => setShowInfo(false)}
onUpdated={onGroupUpdated} onUpdated={onGroupUpdated}
onBack={onBack}
/> />
)} )}
</div> </div>

View File

@@ -0,0 +1,41 @@
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: '' });
useEffect(() => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
window.addEventListener('jama:settings-changed', handler);
return () => window.removeEventListener('jama:settings-changed', handler);
}, []);
const appName = settings.app_name || 'jama';
const logoUrl = settings.logo_url;
// 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">{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

@@ -4,7 +4,7 @@ import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx';
import Avatar from './Avatar.jsx'; import Avatar from './Avatar.jsx';
export default function GroupInfoModal({ group, onClose, onUpdated }) { export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
const { user } = useAuth(); const { user } = useAuth();
const toast = useToast(); const toast = useToast();
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
@@ -12,12 +12,12 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
const [newName, setNewName] = useState(group.name); const [newName, setNewName] = useState(group.name);
const [addSearch, setAddSearch] = useState(''); const [addSearch, setAddSearch] = useState('');
const [addResults, setAddResults] = useState([]); const [addResults, setAddResults] = useState([]);
const [loading, setLoading] = useState(false);
const isDirect = !!group.is_direct;
const isOwner = group.owner_id === user.id; const isOwner = group.owner_id === user.id;
const isAdmin = user.role === 'admin'; const isAdmin = user.role === 'admin';
const canManage = (group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin); const canManage = !isDirect && ((group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin));
const canRename = !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner)); const canRename = !isDirect && !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner));
useEffect(() => { useEffect(() => {
if (group.type === 'private') { if (group.type === 'private') {
@@ -35,24 +35,30 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
if (!newName.trim() || newName === group.name) { setEditing(false); return; } if (!newName.trim() || newName === group.name) { setEditing(false); return; }
try { try {
await api.renameGroup(group.id, newName.trim()); await api.renameGroup(group.id, newName.trim());
toast('Group renamed', 'success'); toast('Renamed', 'success');
onUpdated(); onUpdated();
setEditing(false); setEditing(false);
} catch (e) { toast(e.message, 'error'); } } catch (e) { toast(e.message, 'error'); }
}; };
const handleLeave = async () => { const handleLeave = async () => {
if (!confirm('Leave this group?')) return; if (!confirm('Leave this message?')) return;
try { try {
await api.leaveGroup(group.id); await api.leaveGroup(group.id);
toast('Left group', 'success'); toast('Left message', 'success');
onUpdated();
onClose(); 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'); } } catch (e) { toast(e.message, 'error'); }
}; };
const handleTakeOwnership = async () => { const handleTakeOwnership = async () => {
if (!confirm('Take ownership of this private group? You will be able to see all messages.')) return; if (!confirm('Take ownership of this private group?')) return;
try { try {
await api.takeOwnership(group.id); await api.takeOwnership(group.id);
toast('Ownership taken', 'success'); toast('Ownership taken', 'success');
@@ -64,7 +70,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
const handleAdd = async (u) => { const handleAdd = async (u) => {
try { try {
await api.addMember(group.id, u.id); await api.addMember(group.id, u.id);
toast(`${u.display_name || u.name} added`, 'success'); toast(`${u.name} added`, 'success');
api.getMembers(group.id).then(({ members }) => setMembers(members)); api.getMembers(group.id).then(({ members }) => setMembers(members));
setAddSearch(''); setAddSearch('');
setAddResults([]); setAddResults([]);
@@ -72,29 +78,34 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
}; };
const handleRemove = async (member) => { const handleRemove = async (member) => {
if (!confirm(`Remove ${member.display_name || member.name} from this group?`)) return; if (!confirm(`Remove ${member.name}?`)) return;
try { try {
await api.removeMember(group.id, member.id); await api.removeMember(group.id, member.id);
toast(`${member.display_name || member.name} removed`, 'success'); toast(`${member.name} removed`, 'success');
setMembers(prev => prev.filter(m => m.id !== member.id)); setMembers(prev => prev.filter(m => m.id !== member.id));
} catch (e) { toast(e.message, 'error'); } } catch (e) { toast(e.message, 'error'); }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!confirm('Delete this group? This cannot be undone.')) return; if (!confirm('Delete this message? This cannot be undone.')) return;
try { try {
await api.deleteGroup(group.id); await api.deleteGroup(group.id);
toast('Group deleted', 'success'); toast('Deleted', 'success');
onUpdated(); onUpdated();
onClose(); onClose();
if (onBack) onBack();
} catch (e) { toast(e.message, 'error'); } } 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 ( return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}> <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal"> <div className="modal">
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}> <div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Group Info</h2> <h2 className="modal-title" style={{ margin: 0 }}>Message Info</h2>
<button className="btn-icon" onClick={onClose}> <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> <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> </button>
@@ -120,14 +131,14 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
)} )}
<div className="flex items-center gap-6" style={{ gap: 8, marginTop: 4 }}> <div className="flex items-center gap-6" style={{ gap: 8, marginTop: 4 }}>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}> <span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{group.type === 'public' ? 'Public channel' : 'Private group'} {isDirect ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
</span> </span>
{group.is_readonly && <span className="readonly-badge" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: '#fff3e0', color: '#e65100' }}>Read-only</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>
</div> </div>
{/* Members (private groups) */} {/* Members — shown for private non-direct groups */}
{group.type === 'private' && ( {group.type === 'private' && !isDirect && (
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}> <div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
Members ({members.length}) Members ({members.length})
@@ -136,18 +147,13 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
{members.map(m => ( {members.map(m => (
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}> <div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
<Avatar user={m} size="sm" /> <Avatar user={m} size="sm" />
<span className="flex-1 text-sm">{m.display_name || m.name}</span> <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>} {m.id === group.owner_id && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
{canManage && m.id !== group.owner_id && ( {canManage && m.id !== group.owner_id && (
<button <button
onClick={() => handleRemove(m)} onClick={() => handleRemove(m)}
title="Remove from group" title="Remove"
style={{ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4, lineHeight: 1, transition: 'color var(--transition)' }}
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4,
lineHeight: 1, fontSize: 16,
transition: 'color var(--transition)',
}}
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'} onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'}
> >
@@ -159,16 +165,15 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
</div> </div>
))} ))}
</div> </div>
{canManage && ( {canManage && (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<input className="input" placeholder="Search to add member..." value={addSearch} onChange={e => setAddSearch(e.target.value)} /> <input className="input" placeholder="Search to add member..." value={addSearch} onChange={e => setAddSearch(e.target.value)} />
{addResults.length > 0 && addSearch && ( {addResults.length > 0 && addSearch && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', marginTop: 4, maxHeight: 150, overflowY: 'auto' }}> <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 => ( {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)' }} onClick={() => handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}> <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" /> <Avatar user={u} size="sm" />
<span className="text-sm flex-1">{u.display_name || u.name}</span> <span className="text-sm flex-1">{u.name}</span>
</button> </button>
))} ))}
</div> </div>
@@ -180,16 +185,21 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
{/* Actions */} {/* Actions */}
<div className="flex-col gap-2"> <div className="flex-col gap-2">
{group.type === 'private' && group.owner_id !== user.id && ( {/* 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> <button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Group</button>
)} )}
{isAdmin && group.type === 'private' && group.owner_id !== user.id && ( {/* Admin take ownership (non-direct only) */}
<button className="btn btn-secondary w-full" onClick={handleTakeOwnership}> {!isDirect && isAdmin && group.type === 'private' && !isOwner && (
Take Ownership (Admin) <button className="btn btn-secondary w-full" onClick={handleTakeOwnership}>Take Ownership (Admin)</button>
</button>
)} )}
{(isOwner || (isAdmin && group.type === 'public')) && !group.is_default && ( {/* Delete */}
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete Group</button> {(canDeleteDirect || canDeleteRegular) && (
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete</button>
)} )}
</div> </div>
</div> </div>

View File

@@ -14,6 +14,44 @@
font-weight: 500; 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 { .message-wrapper {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -137,6 +175,13 @@
position: relative; position: relative;
} }
@media (max-width: 767px) {
.msg-bubble {
user-select: none;
-webkit-user-select: none;
}
}
.msg-bubble.out { .msg-bubble.out {
background: var(--primary); background: var(--primary);
color: white; color: white;
@@ -144,7 +189,7 @@
} }
.msg-bubble.in { .msg-bubble.in {
background: white; background: var(--bubble-in);
color: var(--text-primary); color: var(--text-primary);
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -264,3 +309,19 @@
.out .link-preview { background: rgba(255,255,255,0.15); } .out .link-preview { background: rgba(255,255,255,0.15); }
.out .link-title { color: white; } .out .link-title { color: white; }
.out .link-desc { color: rgba(255,255,255,0.8); } .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: 48px;
line-height: 1.1;
margin: 0;
user-select: text;
}

View File

@@ -4,16 +4,34 @@ import UserProfilePopup from './UserProfilePopup.jsx';
import ImageLightbox from './ImageLightbox.jsx'; import ImageLightbox from './ImageLightbox.jsx';
import Picker from '@emoji-mart/react'; import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data'; import data from '@emoji-mart/data';
import { parseTS } from '../utils/api.js';
import './Message.css'; import './Message.css';
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏']; const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
function formatMsgContent(content) { function formatMsgContent(content) {
if (!content) return ''; if (!content) return '';
return content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `<span class="mention">@${name}</span>`); // First handle @mentions
let html = content.replace(/@\[([^\]]+)\]\(\d+\)/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;
} }
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact }) {
// 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 }) {
const [showActions, setShowActions] = useState(false); const [showActions, setShowActions] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const wrapperRef = useRef(null); const wrapperRef = useRef(null);
@@ -25,21 +43,36 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
const isOwn = msg.user_id === currentUser.id; const isOwn = msg.user_id === currentUser.id;
const isDeleted = !!msg.is_deleted; 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 = prevMessage && prevMessage.user_id === msg.user_id &&
prevMessage.type !== 'system' && msg.type !== 'system' &&
parseTS(msg.created_at) - parseTS(prevMessage.created_at) < 60000;
const canDelete = !msg.is_deleted && (
msg.user_id === currentUser.id ||
currentUser.role === 'admin' ||
msg.group_owner_id === currentUser.id
);
// Deleted messages are filtered out by ChatWindow, but guard here too // Deleted messages are filtered out by ChatWindow, but guard here too
if (isDeleted) return null; if (isDeleted) return null;
const canDelete = ( // System messages render as a simple centred notice
msg.user_id === currentUser.id || if (isSystem) {
currentUser.role === 'admin' || return (
(msg.group_owner_id === currentUser.id) <>
{showDateSep && (
<div className="date-separator"><span>{formatDate(msg.created_at)}</span></div>
)}
<div className="system-message">{msg.content}</div>
</>
); );
}
const prevSameUser = prevMessage && prevMessage.user_id === msg.user_id &&
new Date(msg.created_at) - new Date(prevMessage.created_at) < 60000;
const showDateSep = !prevMessage ||
new Date(msg.created_at).toDateString() !== new Date(prevMessage.created_at).toDateString();
const reactionMap = {}; const reactionMap = {};
for (const r of (msg.reactions || [])) { for (const r of (msg.reactions || [])) {
@@ -66,6 +99,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
setShowEmojiPicker(false); setShowEmojiPicker(false);
}; };
const handleCopy = () => {
if (!msg.content) return;
navigator.clipboard.writeText(msg.content).catch(() => {});
};
const handleTogglePicker = () => { const handleTogglePicker = () => {
if (!showEmojiPicker && wrapperRef.current) { if (!showEmojiPicker && wrapperRef.current) {
// If the message is in the top 400px of viewport, open picker downward // If the message is in the top 400px of viewport, open picker downward
@@ -97,11 +135,15 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
<div <div
ref={wrapperRef} ref={wrapperRef}
className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`} className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
> >
{!isOwn && !prevSameUser && ( {!isOwn && !prevSameUser && (
<div ref={avatarRef} style={{ cursor: 'pointer' }} onClick={() => setShowProfile(p => !p)}> <div
ref={avatarRef}
style={{ cursor: 'pointer', borderRadius: '50%', transition: 'box-shadow 0.15s' }}
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" /> <Avatar user={msgUser} size="sm" className="msg-avatar" />
</div> </div>
)} )}
@@ -133,7 +175,10 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
{/* Bubble + actions together so actions hover above bubble */} {/* Bubble + actions together so actions hover above bubble */}
<div className="msg-bubble-wrap"> <div className="msg-bubble-wrap">
<div className="msg-bubble-with-actions"> <div className="msg-bubble-with-actions"
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
>
{/* Actions toolbar — floats above the bubble, aligned to correct side */} {/* Actions toolbar — floats above the bubble, aligned to correct side */}
{!isDeleted && (showActions || showEmojiPicker) && ( {!isDeleted && (showActions || showEmojiPicker) && (
<div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}> <div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}>
@@ -146,6 +191,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
<button className="btn-icon action-btn" onClick={() => onReply(msg)} title="Reply"> <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> <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> </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 && ( {canDelete && (
<button className="btn-icon action-btn danger" onClick={() => onDelete(msg.id)} title="Delete"> <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> <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>
@@ -165,7 +215,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
</div> </div>
)} )}
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}`}> <div className={`msg-bubble ${isOwn ? 'out' : 'in'}${!msg.image_url && isEmojiOnly(msg.content) ? ' emoji-only' : ''}`}>
{msg.image_url && ( {msg.image_url && (
<img <img
src={msg.image_url} src={msg.image_url}
@@ -175,7 +225,9 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
/> />
)} )}
{msg.content && ( {msg.content && (
<p isEmojiOnly(msg.content) && !msg.image_url
? <p className="msg-text emoji-msg">{msg.content}</p>
: <p
className="msg-text" className="msg-text"
dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }} dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }}
/> />
@@ -209,6 +261,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
user={msgUser} user={msgUser}
anchorEl={avatarRef.current} anchorEl={avatarRef.current}
onClose={() => setShowProfile(false)} onClose={() => setShowProfile(false)}
onDirectMessage={onDirectMessage}
/> />
)} )}
{lightboxSrc && ( {lightboxSrc && (
@@ -236,11 +289,11 @@ function LinkPreview({ data: raw }) {
} }
function formatTime(dateStr) { function formatTime(dateStr) {
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return parseTS(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} }
function formatDate(dateStr) { function formatDate(dateStr) {
const d = new Date(dateStr); const d = parseTS(dateStr);
const now = new Date(); const now = new Date();
if (d.toDateString() === now.toDateString()) return 'Today'; if (d.toDateString() === now.toDateString()) return 'Today';
const yest = new Date(now); yest.setDate(yest.getDate() - 1); const yest = new Date(now); yest.setDate(yest.getDate() - 1);

View File

@@ -2,9 +2,13 @@
background: white; background: white;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding: 12px 16px; padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
flex-shrink: 0; /* never compress — always visible above keyboard */
position: relative;
z-index: 2;
} }
.reply-bar-input { .reply-bar-input {
@@ -133,7 +137,7 @@
.msg-input { .msg-input {
width: 100%; width: 100%;
min-height: 40px; min-height: 40px;
max-height: 120px; max-height: calc(1.4em * 5 + 20px); /* 5 lines × line-height + padding */
padding: 10px 14px; padding: 10px 14px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 20px; border-radius: 20px;
@@ -143,9 +147,10 @@
color: var(--text-primary); color: var(--text-primary);
background: var(--surface-variant); background: var(--surface-variant);
transition: border-color var(--transition); transition: border-color var(--transition);
overflow-y: auto; overflow-y: hidden;
resize: none;
} }
.msg-input:focus { outline: none; border-color: var(--primary); background: white; } .msg-input:focus { outline: none; border-color: var(--primary); background: var(--surface-variant); }
.msg-input::placeholder { color: var(--text-tertiary); } .msg-input::placeholder { color: var(--text-tertiary); }
.send-btn { .send-btn {
@@ -166,3 +171,68 @@
} }
.send-btn.active:hover { background: var(--primary-dark); } .send-btn.active:hover { background: var(--primary-dark); }
.send-btn:disabled { opacity: 0.4; cursor: default; } .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) {
.input-row {
min-width: 480px;
}
}

View File

@@ -1,9 +1,17 @@
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { api } from '../utils/api.js'; import { api } from '../utils/api.js';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import './MessageInput.css'; import './MessageInput.css';
const URL_REGEX = /https?:\/\/[^\s]+/g; 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 }) { export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) {
const [text, setText] = useState(''); const [text, setText] = useState('');
const [imageFile, setImageFile] = useState(null); const [imageFile, setImageFile] = useState(null);
@@ -14,11 +22,30 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
const [showMention, setShowMention] = useState(false); const [showMention, setShowMention] = useState(false);
const [linkPreview, setLinkPreview] = useState(null); const [linkPreview, setLinkPreview] = useState(null);
const [loadingPreview, setLoadingPreview] = useState(false); const [loadingPreview, setLoadingPreview] = useState(false);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const inputRef = useRef(null); const inputRef = useRef(null);
const typingTimer = useRef(null); const typingTimer = useRef(null);
const wasTyping = useRef(false); const wasTyping = useRef(false);
const mentionStart = useRef(-1); const mentionStart = useRef(-1);
const fileInput = useRef(null); 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 // Handle typing notification
const handleTypingChange = (value) => { const handleTypingChange = (value) => {
@@ -35,13 +62,26 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
}, 2000); }, 2000);
}; };
// Link preview // Link preview — 5 second timeout, then abandon and enable Send
const previewTimeoutRef = useRef(null);
const fetchPreview = useCallback(async (url) => { const fetchPreview = useCallback(async (url) => {
setLoadingPreview(true); setLoadingPreview(true);
setLinkPreview(null);
if (previewTimeoutRef.current) clearTimeout(previewTimeoutRef.current);
const abandonTimer = setTimeout(() => {
setLoadingPreview(false);
}, 5000);
previewTimeoutRef.current = abandonTimer;
try { try {
const { preview } = await api.getLinkPreview(url); const { preview } = await api.getLinkPreview(url);
clearTimeout(abandonTimer);
if (preview) setLinkPreview(preview); if (preview) setLinkPreview(preview);
} catch {} } catch {
clearTimeout(abandonTimer);
}
setLoadingPreview(false); setLoadingPreview(false);
}, []); }, []);
@@ -50,7 +90,13 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
setText(val); setText(val);
handleTypingChange(val); handleTypingChange(val);
// Detect @mention 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 cur = e.target.selectionStart;
const lastAt = val.lastIndexOf('@', cur - 1); const lastAt = val.lastIndexOf('@', cur - 1);
if (lastAt !== -1) { if (lastAt !== -1) {
@@ -68,7 +114,6 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
} }
setShowMention(false); setShowMention(false);
// Link preview
const urls = val.match(URL_REGEX); const urls = val.match(URL_REGEX);
if (urls && urls[0] !== linkPreview?.url) { if (urls && urls[0] !== linkPreview?.url) {
fetchPreview(urls[0]); fetchPreview(urls[0]);
@@ -112,20 +157,33 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
setImagePreview(null); setImagePreview(null);
wasTyping.current = false; wasTyping.current = false;
onTyping(false); onTyping(false);
if (inputRef.current) {
inputRef.current.style.height = 'auto';
inputRef.current.style.overflowY = 'hidden';
}
await onSend({ content: trimmed || null, imageFile, linkPreview: lp }); // Tag emoji-only messages so they can be rendered large
const emojiOnly = !!trimmed && isEmojiOnly(trimmed);
await onSend({ content: trimmed || null, imageFile, linkPreview: lp, emojiOnly });
};
// Send a single emoji directly (from picker)
const handleEmojiSend = async (emoji) => {
setShowEmojiPicker(false);
await onSend({ content: emoji.native, imageFile: null, linkPreview: null, emojiOnly: true });
}; };
const compressImage = (file) => new Promise((resolve) => { const compressImage = (file) => new Promise((resolve) => {
const MAX_PX = 1920; const MAX_PX = 1920;
const QUALITY = 0.82; const QUALITY = 0.82;
const isPng = file.type === 'image/png';
const img = new Image(); const img = new Image();
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
img.onload = () => { img.onload = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
let { width, height } = img; let { width, height } = img;
if (width <= MAX_PX && height <= MAX_PX) { if (width <= MAX_PX && height <= MAX_PX) {
// Already small enough — still re-encode to strip EXIF and reduce size // already small
} else { } else {
const ratio = Math.min(MAX_PX / width, MAX_PX / height); const ratio = Math.min(MAX_PX / width, MAX_PX / height);
width = Math.round(width * ratio); width = Math.round(width * ratio);
@@ -134,10 +192,17 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height); const ctx = canvas.getContext('2d');
canvas.toBlob(blob => { if (!isPng) {
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' })); ctx.fillStyle = '#ffffff';
}, 'image/jpeg', QUALITY); 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; img.src = url;
}); });
@@ -150,12 +215,11 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => setImagePreview(e.target.result); reader.onload = (e) => setImagePreview(e.target.result);
reader.readAsDataURL(compressed); reader.readAsDataURL(compressed);
setShowAttachMenu(false);
}; };
const displayText = (t) => { // Detect mobile (touch device)
// Convert @[name](id) to @name for display const isMobile = () => window.matchMedia('(pointer: coarse)').matches;
return t.replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1');
};
return ( return (
<div className="message-input-area"> <div className="message-input-area">
@@ -215,10 +279,59 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
)} )}
<div className="input-row"> <div className="input-row">
<button className="btn-icon input-action" onClick={() => fileInput.current?.click()} title="Attach image">
<svg width="20" height="20" 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> {/* + 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> </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={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={handleEmojiSend}
theme="light"
previewPosition="none"
skinTonePosition="none"
maxFrequentRows={2}
/>
</div>
)}
<div className="input-wrap"> <div className="input-wrap">
<textarea <textarea
@@ -234,12 +347,15 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
</div> </div>
<button <button
className={`send-btn ${(text.trim() || imageFile) ? 'active' : ''}`} className={`send-btn ${(text.trim() || imageFile) && !loadingPreview ? 'active' : ''}`}
onClick={handleSend} onClick={handleSend}
disabled={!text.trim() && !imageFile} disabled={(!text.trim() && !imageFile) || loadingPreview}
title="Send" title={loadingPreview ? 'Loading preview…' : 'Send'}
> >
<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> {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> </button>
</div> </div>
</div> </div>

View File

@@ -15,6 +15,9 @@ export default function NewChatModal({ onClose, onCreated }) {
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// True when exactly 1 user selected on private tab = direct message
const isDirect = tab === 'private' && selected.length === 1;
useEffect(() => { useEffect(() => {
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {}); api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
}, []); }, []);
@@ -25,18 +28,37 @@ export default function NewChatModal({ onClose, onCreated }) {
} }
}, [search]); }, [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 () => { const handleCreate = async () => {
if (!name.trim()) return toast('Name required', 'error');
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error'); 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); setLoading(true);
try { try {
const { group } = await api.createGroup({ 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(), name: name.trim(),
type: tab, type: tab,
memberIds: selected.map(u => u.id), memberIds: selected.map(u => u.id),
isReadonly: tab === 'public' && isReadonly, isReadonly: tab === 'public' && isReadonly,
}); };
toast(`${tab === 'public' ? 'Channel' : 'Chat'} created!`, 'success'); }
const { group } = await api.createGroup(payload);
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
onCreated(group); onCreated(group);
} catch (e) { } catch (e) {
toast(e.message, 'error'); toast(e.message, 'error');
@@ -45,10 +67,10 @@ export default function NewChatModal({ onClose, onCreated }) {
} }
}; };
const toggle = (u) => { // Placeholder for the name field
if (u.id === user.id) return; const namePlaceholder = isDirect
setSelected(prev => prev.find(p => p.id === u.id) ? prev.filter(p => p.id !== u.id) : [...prev, u]); ? selected[0]?.name || ''
}; : tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team';
return ( return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}> <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
@@ -62,49 +84,66 @@ export default function NewChatModal({ onClose, onCreated }) {
{user.role === 'admin' && ( {user.role === 'admin' && (
<div className="flex gap-2" style={{ marginBottom: 20 }}> <div className="flex gap-2" style={{ marginBottom: 20 }}>
<button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Private Group</button> <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 Channel</button> <button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Message</button>
</div> </div>
)} )}
{/* Message Name — hidden for direct (1-user) messages */}
{!isDirect && (
<div className="flex-col gap-2" style={{ marginBottom: 16 }}> <div className="flex-col gap-2" style={{ marginBottom: 16 }}>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label>
{tab === 'public' ? 'Channel Name' : 'Group Name'} <input
</label> className="input"
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder={tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team'} autoFocus /> value={name}
onChange={e => setName(e.target.value)}
placeholder={namePlaceholder}
autoFocus={tab === 'public'}
/>
</div> </div>
)}
{/* Readonly toggle for public */}
{tab === 'public' && user.role === 'admin' && ( {tab === 'public' && user.role === 'admin' && (
<label className="flex items-center gap-2 text-sm" style={{ marginBottom: 16, cursor: 'pointer', color: 'var(--text-secondary)' }}> <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)} /> <input type="checkbox" checked={isReadonly} onChange={e => setIsReadonly(e.target.checked)} />
Read-only channel (only admins can post) Read-only message (only admins can post)
</label> </label>
)} )}
{/* Member selector for private tab */}
{tab === 'private' && ( {tab === 'private' && (
<> <>
<div className="flex-col gap-2" style={{ marginBottom: 12 }}> <div className="flex-col gap-2" style={{ marginBottom: 12 }}>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Add Members</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} /> {isDirect ? 'Direct Message with' : 'Add Members'}
</label>
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} autoFocus />
</div> </div>
{selected.length > 0 && ( {selected.length > 0 && (
<div className="flex gap-2" style={{ flexWrap: 'wrap', marginBottom: 12 }}> <div className="flex gap-2" style={{ flexWrap: 'wrap', marginBottom: 12 }}>
{selected.map(u => ( {selected.map(u => (
<span key={u.id} className="chip"> <span key={u.id} className="chip">
{u.display_name || u.name} {u.name}
<span className="chip-remove" onClick={() => toggle(u)}>×</span> <span className="chip-remove" onClick={() => toggle(u)}>×</span>
</span> </span>
))} ))}
</div> </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)' }}> <div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
{users.filter(u => u.id !== user.id).map(u => ( {users.filter(u => u.id !== user.id).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' }}> <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)} /> <input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
<Avatar user={u} size="sm" /> <Avatar user={u} size="sm" />
<span className="flex-1 text-sm">{u.display_name || u.name}</span> <span className="flex-1 text-sm">{u.name}</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span> <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span>
</label> </label>
))} ))}
@@ -115,7 +154,7 @@ export default function NewChatModal({ onClose, onCreated }) {
<div className="flex gap-2 justify-between" style={{ marginTop: 20 }}> <div className="flex gap-2 justify-between" style={{ marginTop: 20 }}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button> <button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={handleCreate} disabled={loading}> <button className="btn btn-primary" onClick={handleCreate} disabled={loading}>
{loading ? 'Creating...' : 'Create'} {loading ? 'Creating...' : isDirect ? 'Start Conversation' : 'Create'}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ export default function ProfileModal({ onClose }) {
const toast = useToast(); const toast = useToast();
const [displayName, setDisplayName] = useState(user?.display_name || ''); const [displayName, setDisplayName] = useState(user?.display_name || '');
const [displayNameWarning, setDisplayNameWarning] = useState('');
const [aboutMe, setAboutMe] = useState(user?.about_me || ''); const [aboutMe, setAboutMe] = useState(user?.about_me || '');
const [currentPw, setCurrentPw] = useState(''); const [currentPw, setCurrentPw] = useState('');
const [newPw, setNewPw] = useState(''); const [newPw, setNewPw] = useState('');
@@ -18,6 +19,7 @@ export default function ProfileModal({ onClose }) {
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag); const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
if (displayNameWarning) return toast('Display name is already in use', 'error');
setLoading(true); setLoading(true);
try { try {
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag }); const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag });
@@ -98,7 +100,24 @@ export default function ProfileModal({ onClose }) {
<div className="flex-col gap-3"> <div className="flex-col gap-3">
<div className="flex-col gap-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Display Name</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Display Name</label>
<input className="input" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={user?.name} /> <input
className="input"
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 }}
/>
{displayNameWarning && <span className="text-xs" style={{ color: '#e53935' }}>{displayNameWarning}</span>}
</div> </div>
<div className="flex-col gap-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label>

View File

@@ -1,12 +1,14 @@
.sidebar { .sidebar {
width: 320px; width: 320px;
min-width: 320px; min-width: 320px;
height: 100vh; height: 100%;
min-height: 0;
background: white; background: white;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
position: relative;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
@@ -17,8 +19,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 16px 16px 12px; padding: 14px 16px 12px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0;
} }
.sidebar-title { .sidebar-title {
@@ -28,42 +31,68 @@
flex: 1; flex: 1;
} }
.sidebar-logo {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: contain;
flex-shrink: 0;
}
.offline-dot { .offline-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #e53935; background: #e53935;
flex-shrink: 0;
} }
.sidebar-search { /* New Chat bar (desktop) */
padding: 12px 12px 8px; .sidebar-newchat-bar {
padding: 10px 12px 6px;
flex-shrink: 0;
} }
.search-wrap { .newchat-btn {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
} gap: 8px;
.search-icon {
position: absolute;
left: 12px;
color: var(--text-tertiary);
pointer-events: none;
}
.search-input {
width: 100%; width: 100%;
padding: 8px 12px 8px 36px; padding: 9px 16px;
border: none;
border-radius: 20px; border-radius: 20px;
background: var(--background); background: var(--primary-light);
color: var(--primary);
font-size: 14px; font-size: 14px;
color: var(--text-primary); font-weight: 600;
font-family: var(--font); border: 1.5px solid var(--primary);
cursor: pointer;
transition: background var(--transition), color var(--transition);
} }
.search-input:focus { outline: none; } .newchat-btn:hover { background: var(--primary); color: white; }
.search-input::placeholder { color: var(--text-tertiary); } .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 { .groups-list {
flex: 1; flex: 1;
@@ -88,7 +117,6 @@
padding: 10px 16px; padding: 10px 16px;
cursor: pointer; cursor: pointer;
transition: background var(--transition); transition: background var(--transition);
border-radius: 0;
} }
.group-item:hover { background: var(--background); } .group-item:hover { background: var(--background); }
.group-item.active { background: var(--primary-light); } .group-item.active { background: var(--primary-light); }
@@ -131,11 +159,12 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Footer */
.sidebar-footer { .sidebar-footer {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding: 8px; padding: 12px 8px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
position: relative; position: relative;
flex-shrink: 0;
} }
.user-footer-btn { .user-footer-btn {
@@ -179,24 +208,8 @@
.footer-menu-item.danger { color: var(--error); } .footer-menu-item.danger { color: var(--error); }
.footer-menu-item.danger:hover { background: #fce8e6; } .footer-menu-item.danger:hover { background: #fce8e6; }
/* App logo in sidebar header */ .group-item.has-unread { background: var(--primary-light); }
.sidebar-logo { .unread-name { font-weight: 700; color: var(--text-primary) !important; }
width: 56px;
height: 56px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
/* Unread message indicator */
.group-item.has-unread {
background: var(--primary-light);
}
.unread-name {
font-weight: 700;
color: var(--text-primary) !important;
}
.badge-unread { .badge-unread {
background: var(--text-secondary); background: var(--text-secondary);
color: white; color: white;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx';
import { useSocket } from '../contexts/SocketContext.jsx'; import { useSocket } from '../contexts/SocketContext.jsx';
import { api } from '../utils/api.js'; import { api, parseTS } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx';
import Avatar from './Avatar.jsx'; import Avatar from './Avatar.jsx';
import './Sidebar.css'; import './Sidebar.css';
@@ -26,41 +26,50 @@ function useAppSettings() {
useEffect(() => { useEffect(() => {
fetchSettings(); fetchSettings();
// Re-fetch when settings are saved from the SettingsModal
window.addEventListener('jama:settings-changed', fetchSettings); window.addEventListener('jama:settings-changed', fetchSettings);
return () => window.removeEventListener('jama:settings-changed', fetchSettings); return () => window.removeEventListener('jama:settings-changed', fetchSettings);
}, []); }, []);
// Update page title and favicon whenever settings change
useEffect(() => { useEffect(() => {
const name = settings.app_name || 'jama'; const name = settings.app_name || 'jama';
// Update <title>
document.title = name; document.title = name;
// Update favicon
const logoUrl = settings.logo_url; const logoUrl = settings.logo_url;
const faviconUrl = logoUrl || '/icons/jama.png'; const faviconUrl = logoUrl || '/icons/jama.png';
let link = document.querySelector("link[rel~='icon']"); let link = document.querySelector("link[rel~='icon']");
if (!link) { if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = faviconUrl; link.href = faviconUrl;
}, [settings]); }, [settings]);
return settings; return settings;
} }
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) { export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated, isMobile, onAbout }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { connected } = useSocket(); const { connected } = useSocket();
const toast = useToast(); const toast = useToast();
const [search, setSearch] = useState('');
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const settings = useAppSettings(); const settings = useAppSettings();
const [dark, setDark] = useTheme(); const [dark, setDark] = useTheme();
const menuRef = useRef(null);
const footerBtnRef = useRef(null);
// Fix 6: swipe right to go back on mobile — handled in ChatWindow, but prevent sidebar swipe exit
// Close menu on click outside
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 appName = settings.app_name || 'jama'; const appName = settings.app_name || 'jama';
const logoUrl = settings.logo_url; const logoUrl = settings.logo_url;
@@ -70,18 +79,12 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
...(groups.privateGroups || []) ...(groups.privateGroups || [])
]; ];
const filtered = search const publicFiltered = allGroups.filter(g => g.type === 'public');
? allGroups.filter(g => g.name.toLowerCase().includes(search.toLowerCase())) const privateFiltered = allGroups.filter(g => g.type === 'private');
: allGroups;
const publicFiltered = filtered.filter(g => g.type === 'public');
const privateFiltered = filtered.filter(g => g.type === 'private');
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length; const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
const handleLogout = async () => { const handleLogout = async () => { await logout(); };
await logout();
};
const GroupItem = ({ group }) => { const GroupItem = ({ group }) => {
const notifs = getNotifCount(group.id); const notifs = getNotifCount(group.id);
@@ -115,36 +118,16 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
return ( return (
<div className="sidebar"> <div className="sidebar">
{/* Header with live app name and logo */} {/* New Chat button replacing search bar */}
<div className="sidebar-header"> <div className="sidebar-newchat-bar">
<div className="flex items-center gap-2 flex-1" style={{ minWidth: 0 }}> {!isMobile && (
{logoUrl ? ( <button className="newchat-btn" onClick={onNewChat}>
<img src={logoUrl} alt={appName} className="sidebar-logo" /> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="18" height="18">
) : (
<img src="/icons/jama.png" alt="jama" className="sidebar-logo" />
)}
<h2 className="sidebar-title truncate">{appName}</h2>
{!connected && <span className="offline-dot" title="Offline" />}
</div>
<button className="btn-icon" onClick={onNewChat} title="New Chat">
{settings.icon_newchat ? (
<img src={settings.icon_newchat} alt="New Chat" style={{ width: 20, height: 20, objectFit: 'contain' }} />
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30">
<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" /> <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> </svg>
)} New Chat
</button> </button>
</div> )}
{/* Search */}
<div className="sidebar-search">
<div className="search-wrap">
<svg className="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input className="search-input" placeholder="Search chats..." value={search} onChange={e => setSearch(e.target.value)} />
</div>
</div> </div>
{/* Groups list */} {/* Groups list */}
@@ -155,25 +138,32 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)} {publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
</div> </div>
)} )}
{privateFiltered.length > 0 && ( {privateFiltered.length > 0 && (
<div className="group-section"> <div className="group-section">
<div className="section-label">DIRECT MESSAGES</div> <div className="section-label">DIRECT MESSAGES</div>
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)} {privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
</div> </div>
)} )}
{allGroups.length === 0 && (
{filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}> <div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
No chats found No chats yet
</div> </div>
)} )}
</div> </div>
{/* Mobile FAB: New Chat button floats above user footer */}
{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>
)}
{/* User footer */} {/* User footer */}
<div className="sidebar-footer"> <div className="sidebar-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}> <button ref={footerBtnRef} className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
<Avatar user={user} size="sm" /> <Avatar user={user} size="sm" />
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}> <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="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
@@ -190,7 +180,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
style={{ flexShrink: 0, padding: 8 }} style={{ flexShrink: 0, padding: 8 }}
> >
{dark ? ( {dark ? (
/* Sun icon — click to go light */
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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"/> <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="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
@@ -199,7 +188,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
<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"/> <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>
) : ( ) : (
/* Moon icon — click to go dark */
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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"/> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg> </svg>
@@ -208,24 +196,29 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
</div> </div>
{showMenu && ( {showMenu && (
<div className="footer-menu" onClick={() => setShowMenu(false)}> <div ref={menuRef} className="footer-menu">
<button className="footer-menu-item" onClick={onProfile}> <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> <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 Profile
</button> </button>
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<> <>
<button className="footer-menu-item" onClick={onUsers}> <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> <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 User Manager
</button> </button>
<button className="footer-menu-item" onClick={onOpenSettings}> <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> <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 Settings
</button> </button>
</> </>
)} )}
<hr className="divider" style={{ margin: '4px 0' }} /> <hr className="divider" style={{ margin: '4px 0' }} />
<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}> <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> <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 Sign out
@@ -239,7 +232,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
function formatTime(dateStr) { function formatTime(dateStr) {
if (!dateStr) return ''; if (!dateStr) return '';
const date = new Date(dateStr); const date = parseTS(dateStr);
const now = new Date(); const now = new Date();
const diff = now - date; const diff = now - date;
if (diff < 86400000 && date.getDate() === now.getDate()) { if (diff < 86400000 && date.getDate() === now.getDate()) {
@@ -250,3 +243,4 @@ function formatTime(dateStr) {
} }
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
} }

View File

@@ -1,37 +1,229 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js'; import { api } from '../utils/api.js';
import Avatar from './Avatar.jsx'; import Avatar from './Avatar.jsx';
import Papa from 'papaparse';
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>
{!!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); } }}
autoFocus
/>
<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(''); } }}
autoFocus
/>
<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 }) { export default function UserManagerModal({ onClose }) {
const { user: me } = useAuth();
const toast = useToast(); const toast = useToast();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [tab, setTab] = useState('users'); // 'users' | 'create' | 'bulk' const [tab, setTab] = useState('users');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' }); const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' });
const [bulkPreview, setBulkPreview] = useState([]);
const [csvFile, setCsvFile] = useState(null);
const [csvRows, setCsvRows] = useState([]);
const [csvInvalid, setCsvInvalid] = useState([]);
const [bulkResult, setBulkResult] = useState(null);
const [bulkLoading, setBulkLoading] = useState(false); const [bulkLoading, setBulkLoading] = useState(false);
const [resetingId, setResetingId] = useState(null);
const [resetPw, setResetPw] = useState('');
const fileRef = useRef(null); const fileRef = useRef(null);
const [userPass, setUserPass] = useState('user@1234');
const load = () => { const load = () => {
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false)); api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
}; };
useEffect(() => {
useEffect(() => { load(); }, []); load();
api.getSettings().then(({ settings }) => {
if (settings.user_pass) setUserPass(settings.user_pass);
}).catch(() => {});
}, []);
const filtered = users.filter(u => const filtered = users.filter(u =>
!search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase()) !search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase())
); );
const handleCreate = async () => { const handleCreate = async () => {
if (!form.name || !form.email || !form.password) return toast('All fields required', 'error'); 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); setCreating(true);
try { try {
await api.createUser(form); await api.createUser(form);
@@ -46,31 +238,28 @@ export default function UserManagerModal({ onClose }) {
} }
}; };
const handleCSV = (e) => { const handleFileSelect = (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
Papa.parse(file, { setCsvFile(file);
header: true, setBulkResult(null);
complete: ({ data }) => { const reader = new FileReader();
const rows = data.filter(r => r.email).map(r => ({ reader.onload = (ev) => {
name: r.name || r.Name || '', const { rows, invalid } = parseCSV(ev.target.result);
email: r.email || r.Email || '', setCsvRows(rows);
password: r.password || r.Password || 'TempPass@123', setCsvInvalid(invalid);
role: (r.role || r.Role || 'member').toLowerCase(), };
})); reader.readAsText(file);
setBulkPreview(rows);
}
});
}; };
const handleBulkImport = async () => { const handleBulkImport = async () => {
if (!bulkPreview.length) return; if (!csvRows.length) return;
setBulkLoading(true); setBulkLoading(true);
try { try {
const results = await api.bulkUsers(bulkPreview); const result = await api.bulkUsers(csvRows);
toast(`Created: ${results.created.length}, Errors: ${results.errors.length}`, results.errors.length ? 'default' : 'success'); setBulkResult(result);
setBulkPreview([]); setCsvRows([]); setCsvFile(null); setCsvInvalid([]);
setTab('users'); if (fileRef.current) fileRef.current.value = '';
load(); load();
} catch (e) { } catch (e) {
toast(e.message, 'error'); toast(e.message, 'error');
@@ -79,48 +268,9 @@ export default function UserManagerModal({ onClose }) {
} }
}; };
const handleRole = async (u, role) => {
try {
await api.updateRole(u.id, role);
toast('Role updated', 'success');
load();
} catch (e) {
toast(e.message, 'error');
}
};
const handleResetPw = async (uid) => {
if (!resetPw || resetPw.length < 6) return toast('Enter a password (min 6 chars)', 'error');
try {
await api.resetPassword(uid, resetPw);
toast('Password reset — user must change on next login', 'success');
setResetingId(null);
setResetPw('');
} catch (e) {
toast(e.message, 'error');
}
};
const handleSuspend = async (u) => {
if (!confirm(`Suspend ${u.name}?`)) return;
try { await api.suspendUser(u.id); toast('User suspended', 'success'); load(); }
catch (e) { toast(e.message, 'error'); }
};
const handleActivate = async (u) => {
try { await api.activateUser(u.id); toast('User activated', 'success'); load(); }
catch (e) { toast(e.message, 'error'); }
};
const handleDelete = async (u) => {
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'); load(); }
catch (e) { toast(e.message, 'error'); }
};
return ( return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}> <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 700 }}> <div className="modal" style={{ maxWidth: 600, width: '100%' }}>
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}> <div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2> <h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
<button className="btn-icon" onClick={onClose}> <button className="btn-icon" onClick={onClose}>
@@ -128,78 +278,22 @@ export default function UserManagerModal({ onClose }) {
</button> </button>
</div> </div>
{/* Tabs */}
<div className="flex gap-2" style={{ marginBottom: 20 }}> <div className="flex gap-2" style={{ marginBottom: 20 }}>
<button className={`btn btn-sm ${tab === 'users' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('users')}> <button className={`btn btn-sm ${tab === 'users' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('users')}>All Users ({users.length})</button>
All Users ({users.length}) <button className={`btn btn-sm ${tab === 'create' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('create')}>+ Create User</button>
</button> <button className={`btn btn-sm ${tab === 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('bulk')}>Bulk Import CSV</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> </div>
{/* Users list */} {/* Users list — accordion */}
{tab === 'users' && ( {tab === 'users' && (
<> <>
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} /> <input className="input" style={{ marginBottom: 12 }} placeholder="Search users" value={search} onChange={e => setSearch(e.target.value)} />
{loading ? ( {loading ? (
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div> <div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
) : ( ) : (
<div style={{ maxHeight: 440, overflowY: 'auto' }}> <div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{filtered.map(u => ( {filtered.map(u => (
<div key={u.id} style={{ borderBottom: '1px solid var(--border)', padding: '12px 0' }}> <UserRow key={u.id} u={u} onUpdated={load} />
<div className="flex items-center gap-2" style={{ gap: 12 }}>
<Avatar user={u} size="sm" />
<div className="flex-col flex-1 overflow-hidden">
<div className="flex items-center gap-2" style={{ gap: 8 }}>
<span className="font-medium text-sm">{u.display_name || 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> : null}
</div>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{u.email}</span>
{u.must_change_password ? <span className="text-xs" style={{ color: 'var(--warning)' }}> Must change password</span> : null}
</div>
{/* Actions */}
{!u.is_default_admin && (
<div className="flex gap-1" style={{ gap: 4 }}>
{resetingId === u.id ? (
<div className="flex gap-1" style={{ gap: 4 }}>
<input className="input" style={{ width: 130, fontSize: 12, padding: '4px 8px' }} type="password" placeholder="New password" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button className="btn btn-primary btn-sm" onClick={() => handleResetPw(u.id)}>Set</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setResetingId(null); setResetPw(''); }}></button>
</div>
) : (
<>
<button className="btn btn-secondary btn-sm" onClick={() => setResetingId(u.id)} title="Reset password">
<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" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Reset PW
</button>
<select
value={u.role}
onChange={e => handleRole(u, e.target.value)}
className="input"
style={{ width: 90, padding: '4px 6px', fontSize: 12 }}
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
{u.status === 'active' ? (
<button className="btn btn-secondary btn-sm" onClick={() => handleSuspend(u)}>Suspend</button>
) : u.status === 'suspended' ? (
<button className="btn btn-secondary btn-sm" onClick={() => handleActivate(u)} style={{ color: 'var(--success)' }}>Activate</button>
) : null}
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(u)}>Delete</button>
</>
)}
</div>
)}
</div>
</div>
))} ))}
</div> </div>
)} )}
@@ -209,20 +303,20 @@ export default function UserManagerModal({ onClose }) {
{/* Create user */} {/* Create user */}
{tab === 'create' && ( {tab === 'create' && (
<div className="flex-col gap-3"> <div className="flex-col gap-3">
<div className="flex gap-3" style={{ gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="flex-col gap-1 flex-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Full Name</label> <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" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /> <input className="input" placeholder="Jane Smith" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
</div> </div>
<div className="flex-col gap-1 flex-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Email</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Email</label>
<input className="input" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} /> <input className="input" type="email" placeholder="jane@example.com" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
</div> </div>
</div> </div>
<div className="flex gap-3" style={{ gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
<div className="flex-col gap-1 flex-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(blank = default)</span></label>
<input className="input" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} /> <input className="input" type="text" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
</div> </div>
<div className="flex-col gap-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Role</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Role</label>
@@ -232,8 +326,8 @@ export default function UserManagerModal({ onClose }) {
</select> </select>
</div> </div>
</div> </div>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>User will be required to change their password on first login.</p> <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> <button className="btn btn-primary" onClick={handleCreate} disabled={creating}>{creating ? 'Creating' : 'Create User'}</button>
</div> </div>
)} )}
@@ -241,45 +335,66 @@ export default function UserManagerModal({ onClose }) {
{tab === 'bulk' && ( {tab === 'bulk' && (
<div className="flex-col gap-4"> <div className="flex-col gap-4">
<div className="card" style={{ background: 'var(--background)', border: '1px dashed var(--border)' }}> <div className="card" style={{ background: 'var(--background)', border: '1px dashed var(--border)' }}>
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>CSV Format</p> <p className="text-sm font-medium" style={{ marginBottom: 6 }}>CSV Format</p>
<code style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'block', background: 'white', padding: 8, borderRadius: 4, border: '1px solid var(--border)' }}> <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>
name,email,password,role{'\n'}
John Doe,john@example.com,TempPass123,member
</code>
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}> <p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
role can be "member" or "admin". Password defaults to TempPass@123 if omitted. All users must change password on first login. 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> </p>
</div> </div>
<label className="btn btn-secondary pointer" style={{ alignSelf: 'flex-start' }}> <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 Select CSV File
<input ref={fileRef} type="file" accept=".csv" style={{ display: 'none' }} onChange={handleCSV} /> <input ref={fileRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileSelect} />
</label> </label>
{csvFile && (
{bulkPreview.length > 0 && ( <span className="text-sm" style={{ color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
<> {csvFile.name}
<div> {csvRows.length > 0 && <span style={{ color: 'var(--text-tertiary)', marginLeft: 6 }}>({csvRows.length} valid)</span>}
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>Preview ({bulkPreview.length} users)</p> </span>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', maxHeight: 200, overflowY: 'auto' }}> )}
{bulkPreview.slice(0, 10).map((u, i) => ( {csvRows.length > 0 && (
<div key={i} className="flex items-center gap-2" style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 13, gap: 12 }}> <button className="btn btn-primary" style={{ flexShrink: 0 }} onClick={handleBulkImport} disabled={bulkLoading}>
<span className="flex-1">{u.name}</span> {bulkLoading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length !== 1 ? 's' : ''}`}
<span style={{ color: 'var(--text-secondary)' }}>{u.email}</span> </button>
<span className={`role-badge role-${u.role}`}>{u.role}</span>
</div>
))}
{bulkPreview.length > 10 && (
<div style={{ padding: '8px 12px', color: 'var(--text-tertiary)', fontSize: 13 }}>
...and {bulkPreview.length - 10} more
</div>
)} )}
</div> </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> </div>
<button className="btn btn-primary" onClick={handleBulkImport} disabled={bulkLoading}>
{bulkLoading ? 'Importing...' : `Import ${bulkPreview.length} Users`}
</button>
</> </>
)} )}
<button className="btn btn-secondary btn-sm" style={{ marginTop: 10 }} onClick={() => setBulkResult(null)}>Dismiss</button>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,8 +1,14 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import Avatar from './Avatar.jsx'; import Avatar from './Avatar.jsx';
import { api } from '../utils/api.js';
import { useAuth } from '../contexts/AuthContext.jsx';
export default function UserProfilePopup({ user, anchorEl, onClose }) { export default function UserProfilePopup({ user: profileUser, anchorEl, onClose, onDirectMessage }) {
const { user: currentUser } = useAuth();
const popupRef = useRef(null); const popupRef = useRef(null);
const [starting, setStarting] = useState(false);
const isSelf = currentUser?.id === profileUser?.id;
useEffect(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
@@ -15,7 +21,6 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [anchorEl, onClose]); }, [anchorEl, onClose]);
// Position near the anchor element
useEffect(() => { useEffect(() => {
if (!popupRef.current || !anchorEl) return; if (!popupRef.current || !anchorEl) return;
const anchor = anchorEl.getBoundingClientRect(); const anchor = anchorEl.getBoundingClientRect();
@@ -23,20 +28,35 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
const viewportH = window.innerHeight; const viewportH = window.innerHeight;
const viewportW = window.innerWidth; const viewportW = window.innerWidth;
// Default: below and to the right of avatar
let top = anchor.bottom + 8; let top = anchor.bottom + 8;
let left = anchor.left; let left = anchor.left;
// Flip up if not enough space below if (top + 260 > viewportH) top = anchor.top - 268;
if (top + 220 > viewportH) top = anchor.top - 228;
// Clamp right edge
if (left + 220 > viewportW) left = viewportW - 228; if (left + 220 > viewportW) left = viewportW - 228;
popup.style.top = `${top}px`; popup.style.top = `${top}px`;
popup.style.left = `${left}px`; popup.style.left = `${left}px`;
}, [anchorEl]); }, [anchorEl]);
if (!user) return null; 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 ( return (
<div <div
@@ -44,7 +64,7 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
style={{ style={{
position: 'fixed', position: 'fixed',
zIndex: 1000, zIndex: 1000,
background: 'white', background: 'var(--surface)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
borderRadius: 16, borderRadius: 16,
boxShadow: '0 8px 30px rgba(0,0,0,0.15)', boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
@@ -56,26 +76,56 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
gap: 8, gap: 8,
}} }}
> >
<Avatar user={user} size="xl" /> <Avatar user={profileUser} size="xl" />
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)', marginBottom: 2 }}> <div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)', marginBottom: 2 }}>
{user.display_name || user.name} {profileUser.name}
</div> </div>
{user.role === 'admin' && !user.hide_admin_tag && ( {profileUser.role === 'admin' && !profileUser.hide_admin_tag && (
<span className="role-badge role-admin" style={{ fontSize: 11 }}>Admin</span> <span className="role-badge role-admin" style={{ fontSize: 11 }}>Admin</span>
)} )}
</div> </div>
{user.about_me && ( {profileUser.about_me && (
<p style={{ <p style={{
fontSize: 13, color: 'var(--text-secondary)', fontSize: 13, color: 'var(--text-secondary)',
textAlign: 'center', lineHeight: 1.5, textAlign: 'center', lineHeight: 1.5,
marginTop: 4, wordBreak: 'break-word', marginTop: 4, wordBreak: 'break-word',
borderTop: '1px solid var(--border)', borderTop: '1px solid var(--border)',
paddingTop: 10, width: '100%' paddingTop: 10, width: '100%',
}}> }}>
{user.about_me} {profileUser.about_me}
</p> </p>
)} )}
{!isSelf && onDirectMessage && (
<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> </div>
); );
} }

View File

@@ -28,7 +28,7 @@
--font: 'Google Sans', 'Roboto', sans-serif; --font: 'Google Sans', 'Roboto', sans-serif;
} }
html, body, #root { height: 100%; font-family: var(--font); color: var(--text-primary); background: var(--background); } html, body, #root { height: 100%; min-width: 320px; font-family: var(--font); color: var(--text-primary); background: var(--background); }
button { font-family: var(--font); cursor: pointer; border: none; background: none; } button { font-family: var(--font); cursor: pointer; border: none; background: none; }
input, textarea { font-family: var(--font); } input, textarea { font-family: var(--font); }
@@ -226,6 +226,9 @@ a { color: inherit; text-decoration: none; }
[data-theme="dark"] .card { background: var(--surface); border-color: var(--border); } [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-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"] .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 { border-color: var(--border); color: var(--primary); }
[data-theme="dark"] .btn-secondary:hover { background: var(--primary-light); } [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"] .search-input { background: var(--surface-variant); color: var(--text-primary); }
@@ -236,6 +239,10 @@ a { color: inherit; text-decoration: none; }
[data-theme="dark"] .footer-menu-item.danger:hover { background: #3a1a1a; } [data-theme="dark"] .footer-menu-item.danger:hover { background: #3a1a1a; }
[data-theme="dark"] .btn-icon { color: var(--text-primary); } [data-theme="dark"] .btn-icon { color: var(--text-primary); }
[data-theme="dark"] .btn-icon:hover { background: var(--surface-variant); } [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"] .msg-actions { background: var(--surface); border-color: var(--border); }
[data-theme="dark"] .reaction-btn:hover { background: var(--surface-variant); } [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"] .emoji-picker-wrap { background: var(--surface); border-color: var(--border); }
@@ -243,3 +250,92 @@ a { color: inherit; text-decoration: none; }
[data-theme="dark"] .load-more-btn { background: var(--surface-variant); color: var(--text-secondary); } [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"] .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; } [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); }

View File

@@ -16,6 +16,7 @@ if ('serviceWorker' in navigator) {
}); });
} }
// Clear badge count when user focuses the app // Clear badge count when user focuses the app
window.addEventListener('focus', () => { window.addEventListener('focus', () => {
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {}); if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});

View File

@@ -1,12 +1,94 @@
.chat-layout { .chat-layout {
display: flex; display: flex;
flex-direction: column;
height: 100vh; height: 100vh;
height: 100dvh;
overflow: hidden; overflow: hidden;
background: var(--background); 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) { @media (max-width: 767px) {
.chat-layout { .chat-layout {
position: relative; 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;
} }

View File

@@ -9,6 +9,8 @@ import ProfileModal from '../components/ProfileModal.jsx';
import UserManagerModal from '../components/UserManagerModal.jsx'; import UserManagerModal from '../components/UserManagerModal.jsx';
import SettingsModal from '../components/SettingsModal.jsx'; import SettingsModal from '../components/SettingsModal.jsx';
import NewChatModal from '../components/NewChatModal.jsx'; import NewChatModal from '../components/NewChatModal.jsx';
import GlobalBar from '../components/GlobalBar.jsx';
import AboutModal from '../components/AboutModal.jsx';
import './Chat.css'; import './Chat.css';
function urlBase64ToUint8Array(base64String) { function urlBase64ToUint8Array(base64String) {
@@ -99,7 +101,8 @@ export default function Chat() {
privateGroups: prev.privateGroups.map(updateGroup), privateGroups: prev.privateGroups.map(updateGroup),
}; };
}); });
// Increment unread count for the group if not currently viewing it // Don't badge: message is from this user, or group is currently open
if (msg.user_id === user?.id) return;
setUnreadGroups(prev => { setUnreadGroups(prev => {
if (msg.group_id === activeGroupId) return prev; if (msg.group_id === activeGroupId) return prev;
const next = new Map(prev); const next = new Map(prev);
@@ -110,14 +113,8 @@ export default function Chat() {
const handleNotification = (notif) => { const handleNotification = (notif) => {
if (notif.type === 'private_message') { if (notif.type === 'private_message') {
// Private message unread is already handled by handleNewMsg above // Badge is already handled by handleNewMsg via message:new socket event.
// (kept for push notification path when socket is not the source) // Nothing to do here for the socket path.
setUnreadGroups(prev => {
if (notif.groupId === activeGroupId) return prev;
const next = new Map(prev);
next.set(notif.groupId, (next.get(notif.groupId) || 0) + 1);
return next;
});
} else { } else {
setNotifications(prev => [notif, ...prev]); setNotifications(prev => [notif, ...prev]);
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000); toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
@@ -127,11 +124,51 @@ export default function Chat() {
socket.on('message:new', handleNewMsg); socket.on('message:new', handleNewMsg);
socket.on('notification:new', handleNotification); 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),
};
});
};
socket.on('group:new', handleGroupNew);
socket.on('group:deleted', handleGroupDeleted);
socket.on('group:updated', handleGroupUpdated);
return () => { return () => {
socket.off('message:new', handleNewMsg); socket.off('message:new', handleNewMsg);
socket.off('notification:new', handleNotification); socket.off('notification:new', handleNotification);
socket.off('group:new', handleGroupNew);
socket.off('group:deleted', handleGroupDeleted);
socket.off('group:updated', handleGroupUpdated);
}; };
}, [socket, toast]); }, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
const selectGroup = (id) => { const selectGroup = (id) => {
setActiveGroupId(id); setActiveGroupId(id);
@@ -141,6 +178,13 @@ export default function Chat() {
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; }); setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
}; };
// Update page title with total unread badge count
useEffect(() => {
const totalUnread = [...unreadGroups.values()].reduce((a, b) => a + b, 0);
const base = document.title.replace(/^\(\d+\)\s*/, '');
document.title = totalUnread > 0 ? `(${totalUnread}) ${base}` : base;
}, [unreadGroups]);
const activeGroup = [ const activeGroup = [
...(groups.publicGroups || []), ...(groups.publicGroups || []),
...(groups.privateGroups || []) ...(groups.privateGroups || [])
@@ -148,6 +192,10 @@ export default function Chat() {
return ( return (
<div className="chat-layout"> <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) && ( {(!isMobile || showSidebar) && (
<Sidebar <Sidebar
groups={groups} groups={groups}
@@ -160,21 +208,26 @@ export default function Chat() {
onUsers={() => setModal('users')} onUsers={() => setModal('users')}
onSettings={() => setModal('settings')} onSettings={() => setModal('settings')}
onGroupsUpdated={loadGroups} onGroupsUpdated={loadGroups}
isMobile={isMobile}
onAbout={() => setModal('about')}
/> />
)} )}
{(!isMobile || !showSidebar) && ( {(!isMobile || !showSidebar) && (
<ChatWindow <ChatWindow
group={activeGroup} group={activeGroup}
onBack={isMobile ? () => setShowSidebar(true) : null} onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
onGroupUpdated={loadGroups} onGroupUpdated={loadGroups}
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
/> />
)} )}
</div>
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />} {modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />} {modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />} {modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} {modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div> </div>
); );
} }

View File

@@ -4,6 +4,17 @@ function getToken() {
return localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token'); 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 = {}) { async function req(method, path, body, opts = {}) {
const token = getToken(); const token = getToken();
const headers = {}; const headers = {};
@@ -45,11 +56,13 @@ export const api = {
searchUsers: (q) => req('GET', `/users/search?q=${encodeURIComponent(q)}`), searchUsers: (q) => req('GET', `/users/search?q=${encodeURIComponent(q)}`),
createUser: (body) => req('POST', '/users', body), createUser: (body) => req('POST', '/users', body),
bulkUsers: (users) => req('POST', '/users/bulk', { users }), 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 }), updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }),
resetPassword: (id, password) => req('PATCH', `/users/${id}/reset-password`, { password }), resetPassword: (id, password) => req('PATCH', `/users/${id}/reset-password`, { password }),
suspendUser: (id) => req('PATCH', `/users/${id}/suspend`), suspendUser: (id) => req('PATCH', `/users/${id}/suspend`),
activateUser: (id) => req('PATCH', `/users/${id}/activate`), activateUser: (id) => req('PATCH', `/users/${id}/activate`),
deleteUser: (id) => req('DELETE', `/users/${id}`), 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 } updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag }
uploadAvatar: (file) => { uploadAvatar: (file) => {
const form = new FormData(); form.append('avatar', file); const form = new FormData(); form.append('avatar', file);