diff --git a/.env.example b/.env.example
index 22baf48..cc42ad2 100644
--- a/.env.example
+++ b/.env.example
@@ -1,15 +1,22 @@
# jama Configuration
# 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
# 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)
ADMIN_NAME=Admin User
ADMIN_EMAIL=admin@jama.local
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
# WARNING: Leave false in production - shows a warning on login page when true
PW_RESET=false
diff --git a/Dockerfile b/Dockerfile
index 8b03c00..2a00bd7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -24,15 +24,18 @@ LABEL org.opencontainers.image.title="jama" \
org.opencontainers.image.created="${BUILD_DATE}" \
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
COPY backend/package*.json ./
RUN npm install --omit=dev
+# Remove build tools after compile to keep image lean
+RUN apk del python3 make g++
+
COPY backend/ ./
COPY --from=builder /app/frontend/dist ./public
diff --git a/README.md b/README.md
index 7752088..08e90ed 100644
--- a/README.md
+++ b/README.md
@@ -7,70 +7,77 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
## Features
-- 🔐 **Authentication** — Login, remember me, forced password change on first login
-- 💬 **Real-time messaging** — WebSocket (Socket.io) powered chat
-- 👥 **Public channels** — Admin-created, all users auto-joined
-- 🔒 **Private groups** — User-created, owner-managed
-- 📷 **Image uploads** — Attach images to messages
-- 💬 **Message quoting** — Reply to any message with preview
-- 😎 **Emoji reactions** — Quick reactions + full emoji picker
-- @**Mentions** — @mention users with autocomplete, they get notified
-- 🔗 **Link previews** — Auto-fetches OG metadata for URLs
-- 📱 **PWA** — Install to home screen, works offline
-- 👤 **Profiles** — Custom avatars, display names, about me
-- ⚙️ **Admin settings** — Custom logo, app name
-- 👨💼 **User management** — Create, suspend, delete, bulk CSV import
-- 📢 **Read-only channels** — Announcement-style public channels
+### Messaging
+- **Real-time messaging** — WebSocket-powered (Socket.io); messages appear instantly across all clients
+- **Image attachments** — Attach and send images; auto-compressed client-side before upload
+- **Message replies** — Quote and reply to any message with an inline preview
+- **Emoji reactions** — Quick-react with common emojis or open the full emoji picker; one reaction per user, replaceable
+- **@Mentions** — Type `@` to search and tag users with autocomplete; mentioned users receive a notification
+- **Link previews** — URLs are automatically expanded with Open Graph metadata (title, image, site name)
+- **Typing indicators** — See when others are composing a message
+- **Image lightbox** — Tap any image to open it full-screen with pinch-to-zoom support
+
+### Channels & Groups
+- **Public channels** — Admin-created; all users are automatically added
+- **Private groups / DMs** — Any user can create; membership is invite-only by the owner
+- **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
-- Docker & Docker Compose
-
-### 1. Build a versioned image
-
-```bash
-# Build and tag as v1.0.0 (also tags :latest)
-./build.sh 1.0.0
-
-# 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**
+| Layer | Technology |
+|---|---|
+| Backend | Node.js, Express, Socket.io |
+| Database | SQLite (better-sqlite3) |
+| Frontend | React 18, Vite |
+| Image processing | sharp |
+| Push notifications | web-push (VAPID) |
+| Containerization | Docker, Docker Compose |
+| Reverse proxy / SSL | Caddy (recommended) |
---
-## 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
# Build and tag as :latest only
diff --git a/about.json.example b/about.json.example
new file mode 100644
index 0000000..94902e1
--- /dev/null
+++ b/about.json.example
@@ -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."
+}
diff --git a/backend/package.json b/backend/package.json
index 7d59524..abe7cac 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
- "name": "teamchat-backend",
- "version": "1.0.0",
+ "name": "jama-backend",
+ "version": "0.3.0",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {
diff --git a/backend/src/index.js b/backend/src/index.js
index bfa479e..c242f8a 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -32,9 +32,10 @@ app.use('/uploads', express.static('/app/uploads'));
// API Routes
app.use('/api/auth', require('./routes/auth'));
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/settings', require('./routes/settings'));
+app.use('/api/about', require('./routes/about'));
app.use('/api/push', pushRouter);
// Link preview proxy
@@ -128,14 +129,27 @@ io.on('connection', (socket) => {
// Broadcast online status
io.emit('user:online', { userId });
+ // Join personal room for direct notifications
+ socket.join(`user:${userId}`);
+
// Join rooms for all user's groups
const db = getDb();
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
for (const g of publicGroups) socket.join(`group:${g.id}`);
-
+
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}`);
+ // 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
socket.on('message:send', async (data) => {
const { groupId, content, replyToId, imageUrl, linkPreview } = data;
@@ -271,12 +285,31 @@ io.on('connection', (socket) => {
socket.on('message:delete', (data) => {
const { messageId } = data;
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;
- const canDelete = message.user_id === userId ||
- (socket.user.role === 'admin' && message.group_type === 'public') ||
- (message.group_type === 'private' && message.group_owner_id === userId);
+ const isAdmin = socket.user.role === 'admin';
+ const isOwner = 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;
diff --git a/backend/src/models/db.js b/backend/src/models/db.js
index feb221e..daeecf0 100644
--- a/backend/src/models/db.js
+++ b/backend/src/models/db.js
@@ -166,6 +166,22 @@ function initDb() {
}
} 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');
return db;
}
diff --git a/backend/src/routes/about.js b/backend/src/routes/about.js
new file mode 100644
index 0000000..6fa2d24
--- /dev/null
+++ b/backend/src/routes/about.js
@@ -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;
diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js
index 11fed65..8b710bc 100644
--- a/backend/src/routes/groups.js
+++ b/backend/src/routes/groups.js
@@ -3,14 +3,52 @@ const router = express.Router();
const { getDb } = require('../models/db');
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
router.get('/', authMiddleware, (req, res) => {
const db = getDb();
const userId = req.user.id;
- // Public groups (all users are members)
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 m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message,
(SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at
@@ -19,9 +57,9 @@ router.get('/', authMiddleware, (req, res) => {
ORDER BY g.is_default DESC, g.name ASC
`).all();
- // Private groups (user is a member)
- const privateGroups = db.prepare(`
- SELECT g.*,
+ // For direct messages, replace name with opposite user's display name
+ const privateGroupsRaw = db.prepare(`
+ SELECT g.*,
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 m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message,
@@ -33,34 +71,98 @@ router.get('/', authMiddleware, (req, res) => {
ORDER BY last_message_at DESC NULLS LAST
`).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 });
});
// Create group
router.post('/', authMiddleware, (req, res) => {
- const { name, type, memberIds, isReadonly } = req.body;
+ const { name, type, memberIds, isReadonly, isDirect } = req.body;
const db = getDb();
if (type === 'public' && req.user.role !== 'admin') {
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(`
+ INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, direct_peer1_id, direct_peer2_id)
+ 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)
- VALUES (?, ?, ?, ?)
+ INSERT INTO groups (name, type, owner_id, is_readonly, is_direct)
+ VALUES (?, ?, ?, ?, 0)
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
const groupId = result.lastInsertRowid;
if (type === 'public') {
- // Add all users to public group
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 (?, ?)');
for (const u of allUsers) insert.run(groupId, u.id);
} else {
- // Add creator
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) {
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
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);
+
+ // Notify all members via socket
+ emitGroupNew(io, groupId);
+
res.json({ group });
});
@@ -77,14 +183,14 @@ router.patch('/:id/rename', authMiddleware, (req, res) => {
const db = getDb();
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.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 === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
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);
+ emitGroupUpdated(io, group.id);
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);
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.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') {
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);
+
+ // 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 });
});
-// 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) => {
const db = getDb();
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' });
}
const targetId = parseInt(req.params.userId);
- if (targetId === group.owner_id) {
- return res.status(400).json({ error: 'Cannot remove the group owner' });
- }
+ if (targetId === group.owner_id) 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);
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.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 });
});
-// Admin take ownership of private group
+// Admin take ownership
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
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 });
});
-// Delete group (admin or private group owner)
+// Delete group
router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb();
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' });
}
+ // 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);
+
+ // Notify all affected users
+ emitGroupDeleted(io, group.id, members);
+
res.json({ success: true });
});
-module.exports = router;
+return router;
+};
diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js
index b287fe1..c8749a0 100644
--- a/backend/src/routes/settings.js
+++ b/backend/src/routes/settings.js
@@ -39,7 +39,8 @@ router.get('/', (req, res) => {
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
if (admin) obj.admin_email = admin.email;
// 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 });
});
diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js
index 6dac2d6..4a92cf5 100644
--- a/backend/src/routes/users.js
+++ b/backend/src/routes/users.js
@@ -2,7 +2,6 @@ const express = require('express');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const path = require('path');
-const fs = require('fs');
const router = express.Router();
const { getDb, addUserToPublicGroups } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
@@ -14,8 +13,8 @@ const avatarStorage = multer.diskStorage({
cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`);
}
});
-const uploadAvatar = multer({
- storage: avatarStorage,
+const uploadAvatar = multer({
+ storage: avatarStorage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
@@ -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)
router.get('/', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
@@ -34,75 +56,102 @@ router.get('/', authMiddleware, adminMiddleware, (req, res) => {
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) => {
const { q } = req.query;
const db = getDb();
const users = db.prepare(`
- SELECT id, name, display_name, avatar, role, status, hide_admin_tag FROM users
+ SELECT id, name, display_name, avatar, role, status, hide_admin_tag FROM users
WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10
`).all(`%${q}%`, `%${q}%`);
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) => {
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 exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
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(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
- `).run(name, email, hash, role === 'admin' ? 'admin' : 'member');
+ `).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
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);
res.json({ user });
});
-// Bulk create users via CSV data
+// Bulk create users
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 results = { created: [], errors: [] };
+ const results = { created: [], skipped: [] };
+ const seenEmails = new Set();
+ const defaultPw = getDefaultPassword(db);
const insertUser = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
`);
- const transaction = db.transaction((users) => {
- for (const u of users) {
- if (!u.name || !u.email || !u.password) {
- results.errors.push({ email: u.email, error: 'Missing required fields' });
- continue;
- }
- const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(u.email);
- if (exists) {
- results.errors.push({ email: u.email, error: 'Email already exists' });
- continue;
- }
- try {
- const hash = bcrypt.hashSync(u.password, 10);
- const r = insertUser.run(u.name, u.email, hash, u.role === 'admin' ? 'admin' : 'member');
- addUserToPublicGroups(r.lastInsertRowid);
- results.created.push(u.email);
- } catch (e) {
- results.errors.push({ email: u.email, error: e.message });
- }
+ for (const u of users) {
+ const email = (u.email || '').trim().toLowerCase();
+ const name = (u.name || '').trim();
+ 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; }
+ if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
+ seenEmails.add(email);
+ const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
+ if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
+ try {
+ const resolvedName = resolveUniqueName(db, name);
+ 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);
+ results.created.push(email);
+ } catch (e) {
+ results.skipped.push({ email, reason: e.message });
}
- });
+ }
- transaction(users);
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)
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
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.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' });
-
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
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);
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' });
-
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
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);
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' });
-
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true });
});
-// Update own profile
+// Update own profile — display name must be unique (req 6)
router.patch('/me/profile', authMiddleware, (req, res) => {
const { displayName, aboutMe, hideAdminTag } = req.body;
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 = ?")
.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);
res.json({ user });
});
-// Upload avatar
-router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), (req, res) => {
+// Upload avatar — resize if needed, skip compression for files under 500 KB
+router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
- 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 });
+ 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 db = getDb();
+ db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
+ 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;
diff --git a/build.sh b/build.sh
index 81bf7d6..be4d3b7 100644
--- a/build.sh
+++ b/build.sh
@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
-VERSION="${1:-latest}"
+VERSION="${1:-0.3.0}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 43355cb..2186691 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -9,9 +9,11 @@ services:
- "${PORT:-3000}:3000"
environment:
- NODE_ENV=production
+ - TZ=${TZ:-UTC}
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
+ - USER_PASS=${USER_PASS:-user@1234}
- PW_RESET=${PW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
- APP_NAME=${APP_NAME:-jama}
diff --git a/frontend/index.html b/frontend/index.html
index e865711..4e6cbdc 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,7 @@
-
+
diff --git a/frontend/package.json b/frontend/package.json
index c99edac..1d630ed 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
- "name": "teamchat-frontend",
- "version": "1.0.0",
+ "name": "jama-frontend",
+ "version": "0.3.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -22,4 +22,4 @@
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.1.4"
}
-}
+}
\ No newline at end of file
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
index 438f460..2580c93 100644
Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ
diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png
index b0a2c1f..fcc45bb 100644
Binary files a/frontend/public/icons/icon-192.png and b/frontend/public/icons/icon-192.png differ
diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png
index 57f160e..eece094 100644
Binary files a/frontend/public/icons/icon-512.png and b/frontend/public/icons/icon-512.png differ
diff --git a/frontend/public/icons/jama.png b/frontend/public/icons/jama.png
index 8874c29..0dd25cf 100644
Binary files a/frontend/public/icons/jama.png and b/frontend/public/icons/jama.png differ
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
index c2776a0..ce5971f 100644
--- a/frontend/public/manifest.json
+++ b/frontend/public/manifest.json
@@ -5,7 +5,7 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
- "orientation": "portrait-primary",
+ "orientation": "any",
"background_color": "#ffffff",
"theme_color": "#1a73e8",
"icons": [
@@ -33,5 +33,6 @@
"type": "image/png",
"purpose": "maskable"
}
- ]
-}
+ ],
+ "min_width": "320px"
+}
\ No newline at end of file
diff --git a/frontend/src/components/AboutModal.jsx b/frontend/src/components/AboutModal.jsx
new file mode 100644
index 0000000..426af5e
--- /dev/null
+++ b/frontend/src/components/AboutModal.jsx
@@ -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 (
+
+ {parts.map((part, i) => (
+
+ {part === 'Claude.ai'
+ ? {part}
+ : part}
+ {i < parts.length - 1 && · }
+
+ ))}
+
+ );
+}
+
+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 (
+ e.target === e.currentTarget && onClose()}>
+
+
+
+
+
+
+
+
+
+
{appName}
+
just another messaging app
+
+
+ {about ? (
+ <>
+
+ {rows.map(({ label, value, builtWith, link }) => (
+
+
{label}
+
+ {builtWith
+ ?
+ : link
+ ? {value}
+ : value}
+
+
+ ))}
+
+ {a.description &&
{a.description}
}
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/ChatWindow.css b/frontend/src/components/ChatWindow.css
index 065ee3f..8e2c3e3 100644
--- a/frontend/src/components/ChatWindow.css
+++ b/frontend/src/components/ChatWindow.css
@@ -5,6 +5,8 @@
background: var(--surface-variant);
overflow: hidden;
min-width: 0;
+ min-height: 0;
+ height: 100%;
}
.chat-window.empty {
@@ -79,11 +81,16 @@
/* Messages */
.messages-container {
flex: 1;
+ min-height: 0; /* critical: allows flex child to shrink below content size */
overflow-y: auto;
+ overflow-x: hidden;
padding: 16px;
display: flex;
flex-direction: column;
gap: 2px;
+ /* Anchor scroll to bottom so new messages appear above the input */
+ scroll-padding-bottom: 0;
+ overscroll-behavior: contain;
}
.load-more-btn {
diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx
index 162a547..bdeb289 100644
--- a/frontend/src/components/ChatWindow.jsx
+++ b/frontend/src/components/ChatWindow.jsx
@@ -8,7 +8,7 @@ import MessageInput from './MessageInput.jsx';
import GroupInfoModal from './GroupInfoModal.jsx';
import './ChatWindow.css';
-export default function ChatWindow({ group, onBack, onGroupUpdated }) {
+export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage }) {
const { socket } = useSocket();
const { user } = useAuth();
const toast = useToast();
@@ -23,6 +23,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
const messagesEndRef = useRef(null);
const messagesTopRef = useRef(null);
const typingTimers = useRef({});
+ const swipeStartX = useRef(null);
+ const swipeStartY = useRef(null);
useEffect(() => {
api.getSettings().then(({ settings }) => {
@@ -33,6 +35,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
return () => window.removeEventListener('jama:settings-changed', handler);
}, []);
+
const scrollToBottom = useCallback((smooth = false) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, []);
@@ -110,7 +113,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
setHasMore(older.length >= 50);
};
- const handleSend = async ({ content, imageFile, linkPreview }) => {
+ const handleSend = async ({ content, imageFile, linkPreview, emojiOnly }) => {
if (!group) return;
const replyId = replyTo?.id;
setReplyTo(null);
@@ -125,7 +128,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
}
} else {
socket?.emit('message:send', {
- groupId: group.id, content, replyToId: replyId, linkPreview
+ groupId: group.id, content, replyToId: replyId, linkPreview, emojiOnly
});
}
} 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 (
-
+
{/* Header */}
{onBack && (
@@ -172,12 +197,12 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
) : null}
- {group.type === 'public' ? 'Public group' : 'Private group'}
+ {group.is_direct ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
-
setShowInfo(true)} title="Group info">
+ setShowInfo(true)} title="Message info">
{iconGroupInfo ? (
-
+
) : (
@@ -203,9 +228,11 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
message={msg}
prevMessage={messages[i - 1]}
currentUser={user}
+ isDirect={!!group.is_direct}
onReply={(m) => setReplyTo(m)}
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
+ onDirectMessage={onDirectMessage}
/>
))}
{typing.length > 0 && (
@@ -236,7 +263,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
) : (
- This channel is read-only
+ This message is read-only
)}
@@ -245,6 +272,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
group={group}
onClose={() => setShowInfo(false)}
onUpdated={onGroupUpdated}
+ onBack={onBack}
/>
)}
diff --git a/frontend/src/components/GlobalBar.jsx b/frontend/src/components/GlobalBar.jsx
new file mode 100644
index 0000000..1ffe19c
--- /dev/null
+++ b/frontend/src/components/GlobalBar.jsx
@@ -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 (
+
+
+
+
{appName}
+
+ {!connected && (
+
+
+ Offline
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/GroupInfoModal.jsx b/frontend/src/components/GroupInfoModal.jsx
index 57bb72c..4513803 100644
--- a/frontend/src/components/GroupInfoModal.jsx
+++ b/frontend/src/components/GroupInfoModal.jsx
@@ -4,7 +4,7 @@ import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.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 toast = useToast();
const [members, setMembers] = useState([]);
@@ -12,12 +12,12 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
const [newName, setNewName] = useState(group.name);
const [addSearch, setAddSearch] = useState('');
const [addResults, setAddResults] = useState([]);
- const [loading, setLoading] = useState(false);
+ const isDirect = !!group.is_direct;
const isOwner = group.owner_id === user.id;
const isAdmin = user.role === 'admin';
- const canManage = (group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin);
- const canRename = !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner));
+ const canManage = !isDirect && ((group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin));
+ const canRename = !isDirect && !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner));
useEffect(() => {
if (group.type === 'private') {
@@ -35,24 +35,30 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
if (!newName.trim() || newName === group.name) { setEditing(false); return; }
try {
await api.renameGroup(group.id, newName.trim());
- toast('Group renamed', 'success');
+ toast('Renamed', 'success');
onUpdated();
setEditing(false);
} catch (e) { toast(e.message, 'error'); }
};
const handleLeave = async () => {
- if (!confirm('Leave this group?')) return;
+ if (!confirm('Leave this message?')) return;
try {
await api.leaveGroup(group.id);
- toast('Left group', 'success');
- onUpdated();
+ toast('Left message', 'success');
onClose();
+ if (isDirect) {
+ // For direct messages: socket group:deleted fired by server handles
+ // removing from sidebar and clearing active group — no manual refresh needed
+ } else {
+ onUpdated();
+ if (onBack) onBack();
+ }
} catch (e) { toast(e.message, 'error'); }
};
const handleTakeOwnership = async () => {
- if (!confirm('Take ownership of this private group? You will be able to see all messages.')) return;
+ if (!confirm('Take ownership of this private group?')) return;
try {
await api.takeOwnership(group.id);
toast('Ownership taken', 'success');
@@ -64,7 +70,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
const handleAdd = async (u) => {
try {
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));
setAddSearch('');
setAddResults([]);
@@ -72,29 +78,34 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
};
const handleRemove = async (member) => {
- if (!confirm(`Remove ${member.display_name || member.name} from this group?`)) return;
+ if (!confirm(`Remove ${member.name}?`)) return;
try {
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));
} catch (e) { toast(e.message, 'error'); }
};
const handleDelete = async () => {
- if (!confirm('Delete this group? This cannot be undone.')) return;
+ if (!confirm('Delete this message? This cannot be undone.')) return;
try {
await api.deleteGroup(group.id);
- toast('Group deleted', 'success');
+ toast('Deleted', 'success');
onUpdated();
onClose();
+ if (onBack) onBack();
} catch (e) { toast(e.message, 'error'); }
};
+ // For direct messages: only show Delete button (owner = remaining user after other left)
+ const canDeleteDirect = isDirect && isOwner;
+ const canDeleteRegular = !isDirect && (isOwner || (isAdmin && group.type === 'public')) && !group.is_default;
+
return (
e.target === e.currentTarget && onClose()}>
-
Group Info
+
Message Info
@@ -120,14 +131,14 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
)}
- {group.type === 'public' ? 'Public channel' : 'Private group'}
+ {isDirect ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
- {group.is_readonly && Read-only }
+ {!!group.is_readonly && Read-only }
- {/* Members (private groups) */}
- {group.type === 'private' && (
+ {/* Members — shown for private non-direct groups */}
+ {group.type === 'private' && !isDirect && (
Members ({members.length})
@@ -136,18 +147,13 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
{members.map(m => (
-
{m.display_name || m.name}
+
{m.name}
{m.id === group.owner_id &&
Owner }
{canManage && m.id !== group.owner_id && (
handleRemove(m)}
- title="Remove from group"
- style={{
- background: 'none', border: 'none', cursor: 'pointer',
- color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4,
- lineHeight: 1, fontSize: 16,
- transition: 'color var(--transition)',
- }}
+ title="Remove"
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4, lineHeight: 1, transition: 'color var(--transition)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'}
>
@@ -159,16 +165,15 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
))}
-
{canManage && (
setAddSearch(e.target.value)} />
{addResults.length > 0 && addSearch && (
-
+
{addResults.filter(u => !members.find(m => m.id === u.id)).map(u => (
-
handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
+ handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
- {u.display_name || u.name}
+ {u.name}
))}
@@ -180,16 +185,21 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
{/* Actions */}
- {group.type === 'private' && group.owner_id !== user.id && (
+ {/* Direct message: leave (if not already owner/last person) */}
+ {isDirect && !isOwner && (
+ Leave Conversation
+ )}
+ {/* Regular private: leave if not owner */}
+ {!isDirect && group.type === 'private' && !isOwner && (
Leave Group
)}
- {isAdmin && group.type === 'private' && group.owner_id !== user.id && (
-
- Take Ownership (Admin)
-
+ {/* Admin take ownership (non-direct only) */}
+ {!isDirect && isAdmin && group.type === 'private' && !isOwner && (
+ Take Ownership (Admin)
)}
- {(isOwner || (isAdmin && group.type === 'public')) && !group.is_default && (
- Delete Group
+ {/* Delete */}
+ {(canDeleteDirect || canDeleteRegular) && (
+ Delete
)}
diff --git a/frontend/src/components/Message.css b/frontend/src/components/Message.css
index 1f7f573..2bc88a7 100644
--- a/frontend/src/components/Message.css
+++ b/frontend/src/components/Message.css
@@ -14,6 +14,44 @@
font-weight: 500;
}
+.system-message {
+ text-align: center;
+ font-size: 12px;
+ color: var(--text-tertiary);
+ font-style: italic;
+ margin: 6px 0;
+ padding: 0 24px;
+}
+
+[data-theme="dark"] .system-message {
+ color: var(--text-secondary);
+}
+
+.msg-link {
+ color: var(--primary);
+ text-decoration: underline;
+ word-break: break-all;
+}
+.msg-link:hover {
+ opacity: 0.8;
+}
+
+/* Own bubble (primary background) — link must be white */
+.msg-bubble.out .msg-link {
+ color: white;
+ text-decoration: underline;
+ opacity: 0.9;
+}
+.msg-bubble.out .msg-link:hover {
+ opacity: 1;
+}
+
+/* Incoming bubble — link should be a dark/contrasting tone, not the same blue as bubble */
+.msg-bubble.in .msg-link {
+ color: var(--primary-dark, #1565c0);
+ text-decoration: underline;
+}
+
.message-wrapper {
display: flex;
align-items: flex-end;
@@ -137,6 +175,13 @@
position: relative;
}
+@media (max-width: 767px) {
+ .msg-bubble {
+ user-select: none;
+ -webkit-user-select: none;
+ }
+}
+
.msg-bubble.out {
background: var(--primary);
color: white;
@@ -144,7 +189,7 @@
}
.msg-bubble.in {
- background: white;
+ background: var(--bubble-in);
color: var(--text-primary);
border-bottom-left-radius: 4px;
box-shadow: var(--shadow-sm);
@@ -264,3 +309,19 @@
.out .link-preview { background: rgba(255,255,255,0.15); }
.out .link-title { color: white; }
.out .link-desc { color: rgba(255,255,255,0.8); }
+
+/* Emoji-only messages: no bubble background, large size */
+.msg-bubble.emoji-only {
+ background: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+ padding: 2px 4px;
+}
+.msg-bubble.emoji-only::after { display: none; }
+
+.msg-text.emoji-msg {
+ font-size: 48px;
+ line-height: 1.1;
+ margin: 0;
+ user-select: text;
+}
diff --git a/frontend/src/components/Message.jsx b/frontend/src/components/Message.jsx
index 92fbf96..aea8290 100644
--- a/frontend/src/components/Message.jsx
+++ b/frontend/src/components/Message.jsx
@@ -4,16 +4,34 @@ import UserProfilePopup from './UserProfilePopup.jsx';
import ImageLightbox from './ImageLightbox.jsx';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
+import { parseTS } from '../utils/api.js';
import './Message.css';
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
function formatMsgContent(content) {
if (!content) return '';
- return content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `
@${name} `);
+ // First handle @mentions
+ let html = content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `
@${name} `);
+ // 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 `
${trimmed} ${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 [showEmojiPicker, setShowEmojiPicker] = useState(false);
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 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
if (isDeleted) return null;
- const canDelete = (
- msg.user_id === currentUser.id ||
- currentUser.role === 'admin' ||
- (msg.group_owner_id === currentUser.id)
- );
-
- 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();
+ // System messages render as a simple centred notice
+ if (isSystem) {
+ return (
+ <>
+ {showDateSep && (
+
{formatDate(msg.created_at)}
+ )}
+
{msg.content}
+ >
+ );
+ }
const reactionMap = {};
for (const r of (msg.reactions || [])) {
@@ -66,6 +99,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
setShowEmojiPicker(false);
};
+ const handleCopy = () => {
+ if (!msg.content) return;
+ navigator.clipboard.writeText(msg.content).catch(() => {});
+ };
+
const handleTogglePicker = () => {
if (!showEmojiPicker && wrapperRef.current) {
// 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
setShowActions(true)}
- onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
>
{!isOwn && !prevSameUser && (
-
setShowProfile(p => !p)}>
+
setShowProfile(p => !p)}
+ onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'}
+ onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
+ >
)}
@@ -133,7 +175,10 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
{/* Bubble + actions together so actions hover above bubble */}
-
+
setShowActions(true)}
+ onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
+ >
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
{!isDeleted && (showActions || showEmojiPicker) && (
@@ -146,6 +191,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
onReply(msg)} title="Reply">
+ {msg.content && (
+
+
+
+ )}
{canDelete && (
onDelete(msg.id)} title="Delete">
@@ -165,7 +215,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
)}
-
+
{msg.image_url && (
)}
{msg.content && (
-
+ isEmojiOnly(msg.content) && !msg.image_url
+ ?
{msg.content}
+ :
)}
{msg.link_preview &&
}
@@ -209,6 +261,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
user={msgUser}
anchorEl={avatarRef.current}
onClose={() => setShowProfile(false)}
+ onDirectMessage={onDirectMessage}
/>
)}
{lightboxSrc && (
@@ -236,11 +289,11 @@ function LinkPreview({ data: raw }) {
}
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) {
- const d = new Date(dateStr);
+ const d = parseTS(dateStr);
const now = new Date();
if (d.toDateString() === now.toDateString()) return 'Today';
const yest = new Date(now); yest.setDate(yest.getDate() - 1);
diff --git a/frontend/src/components/MessageInput.css b/frontend/src/components/MessageInput.css
index 17df452..731ca2d 100644
--- a/frontend/src/components/MessageInput.css
+++ b/frontend/src/components/MessageInput.css
@@ -2,9 +2,13 @@
background: white;
border-top: 1px solid var(--border);
padding: 12px 16px;
+ padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
gap: 8px;
+ flex-shrink: 0; /* never compress — always visible above keyboard */
+ position: relative;
+ z-index: 2;
}
.reply-bar-input {
@@ -133,7 +137,7 @@
.msg-input {
width: 100%;
min-height: 40px;
- max-height: 120px;
+ max-height: calc(1.4em * 5 + 20px); /* 5 lines × line-height + padding */
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 20px;
@@ -143,9 +147,10 @@
color: var(--text-primary);
background: var(--surface-variant);
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); }
.send-btn {
@@ -166,3 +171,68 @@
}
.send-btn.active:hover { background: var(--primary-dark); }
.send-btn:disabled { opacity: 0.4; cursor: default; }
+
+/* + attach button */
+.attach-wrap {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.attach-btn {
+ color: var(--primary);
+}
+.attach-btn:hover {
+ color: var(--primary-dark);
+}
+
+/* Attach menu popup */
+.attach-menu {
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 0;
+ background: white;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+ z-index: 100;
+ min-width: 140px;
+}
+
+.attach-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 11px 16px;
+ width: 100%;
+ font-size: 14px;
+ color: var(--text-primary);
+ transition: var(--transition);
+ white-space: nowrap;
+}
+.attach-item:hover {
+ background: var(--primary-light);
+ color: var(--primary);
+}
+.attach-item svg {
+ flex-shrink: 0;
+ color: var(--text-secondary);
+}
+.attach-item:hover svg {
+ color: var(--primary);
+}
+
+/* Emoji picker popover — positioned above the input area */
+.emoji-input-picker {
+ position: absolute;
+ bottom: calc(100% + 4px);
+ left: 0;
+ z-index: 200;
+}
+
+/* PC only: enforce minimum width on the input row so send button never disappears */
+@media (pointer: fine) {
+ .input-row {
+ min-width: 480px;
+ }
+}
diff --git a/frontend/src/components/MessageInput.jsx b/frontend/src/components/MessageInput.jsx
index 6c71901..627078a 100644
--- a/frontend/src/components/MessageInput.jsx
+++ b/frontend/src/components/MessageInput.jsx
@@ -1,9 +1,17 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { api } from '../utils/api.js';
+import data from '@emoji-mart/data';
+import Picker from '@emoji-mart/react';
import './MessageInput.css';
const URL_REGEX = /https?:\/\/[^\s]+/g;
+// Detect if a string is purely emoji characters (no other text)
+function isEmojiOnly(str) {
+ const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u;
+ return emojiRegex.test(str.trim());
+}
+
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) {
const [text, setText] = useState('');
const [imageFile, setImageFile] = useState(null);
@@ -14,11 +22,30 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
const [showMention, setShowMention] = useState(false);
const [linkPreview, setLinkPreview] = useState(null);
const [loadingPreview, setLoadingPreview] = useState(false);
+ const [showAttachMenu, setShowAttachMenu] = useState(false);
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const inputRef = useRef(null);
const typingTimer = useRef(null);
const wasTyping = useRef(false);
const mentionStart = useRef(-1);
const fileInput = useRef(null);
+ const cameraInput = useRef(null);
+ const attachMenuRef = useRef(null);
+ const emojiPickerRef = useRef(null);
+
+ // Close attach menu / emoji picker on outside click
+ useEffect(() => {
+ const handler = (e) => {
+ if (attachMenuRef.current && !attachMenuRef.current.contains(e.target)) {
+ setShowAttachMenu(false);
+ }
+ if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target)) {
+ setShowEmojiPicker(false);
+ }
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, []);
// Handle typing notification
const handleTypingChange = (value) => {
@@ -35,13 +62,26 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
}, 2000);
};
- // Link preview
+ // Link preview — 5 second timeout, then abandon and enable Send
+ const previewTimeoutRef = useRef(null);
+
const fetchPreview = useCallback(async (url) => {
setLoadingPreview(true);
+ setLinkPreview(null);
+
+ if (previewTimeoutRef.current) clearTimeout(previewTimeoutRef.current);
+ const abandonTimer = setTimeout(() => {
+ setLoadingPreview(false);
+ }, 5000);
+ previewTimeoutRef.current = abandonTimer;
+
try {
const { preview } = await api.getLinkPreview(url);
+ clearTimeout(abandonTimer);
if (preview) setLinkPreview(preview);
- } catch {}
+ } catch {
+ clearTimeout(abandonTimer);
+ }
setLoadingPreview(false);
}, []);
@@ -50,7 +90,13 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
setText(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 lastAt = val.lastIndexOf('@', cur - 1);
if (lastAt !== -1) {
@@ -68,7 +114,6 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
}
setShowMention(false);
- // Link preview
const urls = val.match(URL_REGEX);
if (urls && urls[0] !== linkPreview?.url) {
fetchPreview(urls[0]);
@@ -112,20 +157,33 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
setImagePreview(null);
wasTyping.current = 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 MAX_PX = 1920;
const QUALITY = 0.82;
+ const isPng = file.type === 'image/png';
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let { width, height } = img;
if (width <= MAX_PX && height <= MAX_PX) {
- // Already small enough — still re-encode to strip EXIF and reduce size
+ // already small
} else {
const ratio = Math.min(MAX_PX / width, MAX_PX / height);
width = Math.round(width * ratio);
@@ -134,10 +192,17 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
- canvas.getContext('2d').drawImage(img, 0, 0, width, height);
- canvas.toBlob(blob => {
- resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
- }, 'image/jpeg', QUALITY);
+ const ctx = canvas.getContext('2d');
+ if (!isPng) {
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, width, height);
+ }
+ ctx.drawImage(img, 0, 0, width, height);
+ if (isPng) {
+ canvas.toBlob(blob => resolve(new File([blob], file.name, { type: 'image/png' })), 'image/png');
+ } else {
+ canvas.toBlob(blob => resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' })), 'image/jpeg', QUALITY);
+ }
};
img.src = url;
});
@@ -150,12 +215,11 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
const reader = new FileReader();
reader.onload = (e) => setImagePreview(e.target.result);
reader.readAsDataURL(compressed);
+ setShowAttachMenu(false);
};
- const displayText = (t) => {
- // Convert @[name](id) to @name for display
- return t.replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1');
- };
+ // Detect mobile (touch device)
+ const isMobile = () => window.matchMedia('(pointer: coarse)').matches;
return (
@@ -215,10 +279,59 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
)}
-
fileInput.current?.click()} title="Attach image">
-
-
+
+ {/* + button — attach menu trigger */}
+
+
{ setShowAttachMenu(v => !v); setShowEmojiPicker(false); }}
+ title="Add photo or emoji"
+ >
+
+
+
+
+
+ {showAttachMenu && (
+
+ {/* Photo from library */}
+
fileInput.current?.click()}>
+
+ Photo
+
+ {/* Camera — mobile only */}
+ {isMobile() && (
+
cameraInput.current?.click()}>
+
+ Camera
+
+ )}
+ {/* Emoji */}
+
{ setShowAttachMenu(false); setShowEmojiPicker(true); }}>
+
+ Emoji
+
+
+ )}
+
+
+ {/* Hidden file inputs */}
+
+
+ {/* Emoji picker popover */}
+ {showEmojiPicker && (
+
+ )}
diff --git a/frontend/src/components/NewChatModal.jsx b/frontend/src/components/NewChatModal.jsx
index 4816d72..74c4b4d 100644
--- a/frontend/src/components/NewChatModal.jsx
+++ b/frontend/src/components/NewChatModal.jsx
@@ -15,6 +15,9 @@ export default function NewChatModal({ onClose, onCreated }) {
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
+ // True when exactly 1 user selected on private tab = direct message
+ const isDirect = tab === 'private' && selected.length === 1;
+
useEffect(() => {
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
}, []);
@@ -25,18 +28,37 @@ export default function NewChatModal({ onClose, onCreated }) {
}
}, [search]);
+ const toggle = (u) => {
+ if (u.id === user.id) return;
+ setSelected(prev => prev.find(p => p.id === u.id) ? prev.filter(p => p.id !== u.id) : [...prev, u]);
+ };
+
const handleCreate = async () => {
- if (!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 > 1 && !name.trim()) return toast('Name required', 'error');
+ if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
+
setLoading(true);
try {
- const { group } = await api.createGroup({
- name: name.trim(),
- type: tab,
- memberIds: selected.map(u => u.id),
- isReadonly: tab === 'public' && isReadonly,
- });
- toast(`${tab === 'public' ? 'Channel' : 'Chat'} created!`, 'success');
+ let payload;
+ if (isDirect) {
+ // Direct message: no name, isDirect flag
+ payload = {
+ type: 'private',
+ memberIds: selected.map(u => u.id),
+ isDirect: true,
+ };
+ } else {
+ payload = {
+ name: name.trim(),
+ type: tab,
+ memberIds: selected.map(u => u.id),
+ isReadonly: tab === 'public' && isReadonly,
+ };
+ }
+
+ const { group } = await api.createGroup(payload);
+ toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
onCreated(group);
} catch (e) {
toast(e.message, 'error');
@@ -45,10 +67,10 @@ export default function NewChatModal({ onClose, onCreated }) {
}
};
- 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]);
- };
+ // Placeholder for the name field
+ const namePlaceholder = isDirect
+ ? selected[0]?.name || ''
+ : tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team';
return (
e.target === e.currentTarget && onClose()}>
@@ -62,49 +84,66 @@ export default function NewChatModal({ onClose, onCreated }) {
{user.role === 'admin' && (
- setTab('private')}>Private Group
- setTab('public')}>Public Channel
+ setTab('private')}>Direct Message
+ setTab('public')}>Public Message
)}
-
-
- {tab === 'public' ? 'Channel Name' : 'Group Name'}
-
- setName(e.target.value)} placeholder={tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team'} autoFocus />
-
+ {/* Message Name — hidden for direct (1-user) messages */}
+ {!isDirect && (
+
+ Message Name
+ setName(e.target.value)}
+ placeholder={namePlaceholder}
+ autoFocus={tab === 'public'}
+ />
+
+ )}
+ {/* Readonly toggle for public */}
{tab === 'public' && user.role === 'admin' && (
setIsReadonly(e.target.checked)} />
- Read-only channel (only admins can post)
+ Read-only message (only admins can post)
)}
+ {/* Member selector for private tab */}
{tab === 'private' && (
<>
- Add Members
- setSearch(e.target.value)} />
+
+ {isDirect ? 'Direct Message with' : 'Add Members'}
+
+ setSearch(e.target.value)} autoFocus />
{selected.length > 0 && (
{selected.map(u => (
- {u.display_name || u.name}
+ {u.name}
toggle(u)}>×
))}
)}
+ {isDirect && (
+
+ A private two-person conversation. Select a second person to create a group instead.
+
+ )}
+
{users.filter(u => u.id !== user.id).map(u => (
s.id === u.id)} onChange={() => toggle(u)} />
- {u.display_name || u.name}
+ {u.name}
{u.role}
))}
@@ -115,7 +154,7 @@ export default function NewChatModal({ onClose, onCreated }) {
Cancel
- {loading ? 'Creating...' : 'Create'}
+ {loading ? 'Creating...' : isDirect ? 'Start Conversation' : 'Create'}
diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx
index fb8b4ec..62ff9cd 100644
--- a/frontend/src/components/ProfileModal.jsx
+++ b/frontend/src/components/ProfileModal.jsx
@@ -9,6 +9,7 @@ export default function ProfileModal({ onClose }) {
const toast = useToast();
const [displayName, setDisplayName] = useState(user?.display_name || '');
+ const [displayNameWarning, setDisplayNameWarning] = useState('');
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
const [currentPw, setCurrentPw] = useState('');
const [newPw, setNewPw] = useState('');
@@ -18,6 +19,7 @@ export default function ProfileModal({ onClose }) {
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const handleSaveProfile = async () => {
+ if (displayNameWarning) return toast('Display name is already in use', 'error');
setLoading(true);
try {
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag });
@@ -98,7 +100,24 @@ export default function ProfileModal({ onClose }) {
Display Name
- setDisplayName(e.target.value)} placeholder={user?.name} />
+ {
+ 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 && {displayNameWarning} }
About Me
diff --git a/frontend/src/components/Sidebar.css b/frontend/src/components/Sidebar.css
index 9fb7e7c..3632a0b 100644
--- a/frontend/src/components/Sidebar.css
+++ b/frontend/src/components/Sidebar.css
@@ -1,12 +1,14 @@
.sidebar {
width: 320px;
min-width: 320px;
- height: 100vh;
+ height: 100%;
+ min-height: 0;
background: white;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
+ position: relative;
}
@media (max-width: 767px) {
@@ -17,8 +19,9 @@
display: flex;
align-items: center;
gap: 8px;
- padding: 16px 16px 12px;
+ padding: 14px 16px 12px;
border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
}
.sidebar-title {
@@ -28,42 +31,68 @@
flex: 1;
}
+.sidebar-logo {
+ width: 40px;
+ height: 40px;
+ border-radius: 4px;
+ object-fit: contain;
+ flex-shrink: 0;
+}
+
.offline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #e53935;
+ flex-shrink: 0;
}
-.sidebar-search {
- padding: 12px 12px 8px;
+/* New Chat bar (desktop) */
+.sidebar-newchat-bar {
+ padding: 10px 12px 6px;
+ flex-shrink: 0;
}
-.search-wrap {
- position: relative;
+.newchat-btn {
display: flex;
align-items: center;
-}
-
-.search-icon {
- position: absolute;
- left: 12px;
- color: var(--text-tertiary);
- pointer-events: none;
-}
-
-.search-input {
+ gap: 8px;
width: 100%;
- padding: 8px 12px 8px 36px;
- border: none;
+ padding: 9px 16px;
border-radius: 20px;
- background: var(--background);
+ background: var(--primary-light);
+ color: var(--primary);
font-size: 14px;
- color: var(--text-primary);
- font-family: var(--font);
+ font-weight: 600;
+ border: 1.5px solid var(--primary);
+ cursor: pointer;
+ transition: background var(--transition), color var(--transition);
}
-.search-input:focus { outline: none; }
-.search-input::placeholder { color: var(--text-tertiary); }
+.newchat-btn:hover { background: var(--primary); color: white; }
+.newchat-btn:hover svg { stroke: white; }
+
+/* Mobile FAB */
+.newchat-fab {
+ position: absolute;
+ bottom: 80px;
+ right: 16px;
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ background: var(--primary);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.25);
+ z-index: 10;
+ cursor: pointer;
+ transition: background var(--transition), transform 80ms ease;
+ border: none;
+ pointer-events: auto;
+}
+.newchat-fab:hover { transform: scale(1.05); }
+.newchat-fab:active { transform: scale(0.97); }
.groups-list {
flex: 1;
@@ -88,7 +117,6 @@
padding: 10px 16px;
cursor: pointer;
transition: background var(--transition);
- border-radius: 0;
}
.group-item:hover { background: var(--background); }
.group-item.active { background: var(--primary-light); }
@@ -131,11 +159,12 @@
color: var(--text-secondary);
}
-/* Footer */
.sidebar-footer {
border-top: 1px solid var(--border);
- padding: 8px;
+ padding: 12px 8px;
+ padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
position: relative;
+ flex-shrink: 0;
}
.user-footer-btn {
@@ -179,24 +208,8 @@
.footer-menu-item.danger { color: var(--error); }
.footer-menu-item.danger:hover { background: #fce8e6; }
-/* App logo in sidebar header */
-.sidebar-logo {
- 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;
-}
+.group-item.has-unread { background: var(--primary-light); }
+.unread-name { font-weight: 700; color: var(--text-primary) !important; }
.badge-unread {
background: var(--text-secondary);
color: white;
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index e3e715f..e4facb9 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -1,7 +1,7 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext.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 Avatar from './Avatar.jsx';
import './Sidebar.css';
@@ -26,41 +26,50 @@ function useAppSettings() {
useEffect(() => {
fetchSettings();
- // Re-fetch when settings are saved from the SettingsModal
window.addEventListener('jama:settings-changed', fetchSettings);
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
}, []);
- // Update page title and favicon whenever settings change
useEffect(() => {
const name = settings.app_name || 'jama';
-
- // Update
document.title = name;
-
- // Update favicon
const logoUrl = settings.logo_url;
const faviconUrl = logoUrl || '/icons/jama.png';
let link = document.querySelector("link[rel~='icon']");
- if (!link) {
- link = document.createElement('link');
- link.rel = 'icon';
- document.head.appendChild(link);
- }
+ if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
link.href = faviconUrl;
}, [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 { connected } = useSocket();
const toast = useToast();
- const [search, setSearch] = useState('');
const [showMenu, setShowMenu] = useState(false);
const settings = useAppSettings();
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 logoUrl = settings.logo_url;
@@ -70,18 +79,12 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
...(groups.privateGroups || [])
];
- const filtered = search
- ? allGroups.filter(g => g.name.toLowerCase().includes(search.toLowerCase()))
- : allGroups;
-
- const publicFiltered = filtered.filter(g => g.type === 'public');
- const privateFiltered = filtered.filter(g => g.type === 'private');
+ const publicFiltered = allGroups.filter(g => g.type === 'public');
+ const privateFiltered = allGroups.filter(g => g.type === 'private');
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
- const handleLogout = async () => {
- await logout();
- };
+ const handleLogout = async () => { await logout(); };
const GroupItem = ({ group }) => {
const notifs = getNotifCount(group.id);
@@ -115,36 +118,16 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
return (
- {/* Header with live app name and logo */}
-
-
- {logoUrl ? (
-
- ) : (
-
- )}
-
{appName}
- {!connected &&
}
-
-
- {settings.icon_newchat ? (
-
- ) : (
-
-
+ {/* New Chat button replacing search bar */}
+
+ {!isMobile && (
+
+
+
- )}
-
-
-
- {/* Search */}
-
-
-
-
-
- setSearch(e.target.value)} />
-
+ New Chat
+
+ )}
{/* Groups list */}
@@ -155,25 +138,32 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{publicFiltered.map(g => )}
)}
-
{privateFiltered.length > 0 && (
DIRECT MESSAGES
{privateFiltered.map(g =>
)}
)}
-
- {filtered.length === 0 && (
+ {allGroups.length === 0 && (
- No chats found
+ No chats yet
)}
+ {/* Mobile FAB: New Chat button floats above user footer */}
+ {isMobile && (
+
+
+
+
+
+ )}
+
{/* User footer */}
-
setShowMenu(!showMenu)}>
+ setShowMenu(!showMenu)}>
{user?.display_name || user?.name}
@@ -190,7 +180,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
style={{ flexShrink: 0, padding: 8 }}
>
{dark ? (
- /* Sun icon — click to go light */
@@ -199,7 +188,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
) : (
- /* Moon icon — click to go dark */
@@ -208,24 +196,29 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{showMenu && (
- setShowMenu(false)}>
-
+
+
{ setShowMenu(false); onProfile(); }}>
Profile
{user?.role === 'admin' && (
<>
-
+ { setShowMenu(false); onUsers(); }}>
User Manager
-
+ { setShowMenu(false); onOpenSettings(); }}>
Settings
>
)}
+ { setShowMenu(false); onAbout && onAbout(); }}>
+
+ About
+
+
Sign out
@@ -239,7 +232,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
function formatTime(dateStr) {
if (!dateStr) return '';
- const date = new Date(dateStr);
+ const date = parseTS(dateStr);
const now = new Date();
const diff = now - date;
if (diff < 86400000 && date.getDate() === now.getDate()) {
@@ -250,3 +243,4 @@ function formatTime(dateStr) {
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
+
diff --git a/frontend/src/components/UserManagerModal.jsx b/frontend/src/components/UserManagerModal.jsx
index 50c5041..15d7899 100644
--- a/frontend/src/components/UserManagerModal.jsx
+++ b/frontend/src/components/UserManagerModal.jsx
@@ -1,37 +1,229 @@
import { useState, useEffect, useRef } from 'react';
-import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
import Avatar from './Avatar.jsx';
-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 2–4 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 (
+
+ {/* Row header — always visible */}
+
{ 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)',
+ }}
+ >
+
+
+
+ {u.name}
+ {u.role}
+ {u.status !== 'active' && {u.status} }
+ {!!u.is_default_admin && Default Admin }
+
+
{u.email}
+ {!!u.must_change_password &&
⚠ Must change password
}
+
+
+
+
+
+
+ {/* Accordion panel */}
+ {open && !u.is_default_admin && (
+
+
+ {/* Edit name */}
+ {editName ? (
+
+ setNameVal(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditName(false); setNameVal(u.name); } }}
+ autoFocus
+ />
+ Save
+ { setEditName(false); setNameVal(u.name); }}>✕
+
+ ) : (
+
{ setEditName(true); setShowReset(false); }}
+ >
+
+ Edit Name
+
+ )}
+
+ {/* Role selector */}
+
+ handleRole(e.target.value)}
+ className="input"
+ style={{ width: 140, padding: '5px 8px', fontSize: 13, borderColor: roleWarning ? '#e53935' : undefined }}
+ >
+ User Role
+ Member
+ Admin
+
+ {roleWarning && Role Required }
+
+
+ {/* Reset password */}
+ {showReset ? (
+
+ setResetPw(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') handleResetPw(); if (e.key === 'Escape') { setShowReset(false); setResetPw(''); } }}
+ autoFocus
+ />
+ Set
+ { setShowReset(false); setResetPw(''); }}>✕
+
+ ) : (
+
{ setShowReset(true); setEditName(false); }}
+ >
+
+ Reset Password
+
+ )}
+
+ {/* Suspend / Activate / Delete */}
+
+ {u.status === 'active' ? (
+ Suspend
+ ) : u.status === 'suspended' ? (
+ Activate
+ ) : null}
+ Delete User
+
+
+ )}
+
+ );
+}
export default function UserManagerModal({ onClose }) {
- const { user: me } = useAuth();
const toast = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
- const [tab, setTab] = useState('users'); // 'users' | 'create' | 'bulk'
+ const [tab, setTab] = useState('users');
const [creating, setCreating] = useState(false);
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 [resetingId, setResetingId] = useState(null);
- const [resetPw, setResetPw] = useState('');
+
const fileRef = useRef(null);
+ const [userPass, setUserPass] = useState('user@1234');
const load = () => {
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
};
-
- useEffect(() => { load(); }, []);
+ useEffect(() => {
+ load();
+ api.getSettings().then(({ settings }) => {
+ if (settings.user_pass) setUserPass(settings.user_pass);
+ }).catch(() => {});
+ }, []);
const filtered = users.filter(u =>
!search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase())
);
const handleCreate = async () => {
- if (!form.name || !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);
try {
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];
if (!file) return;
- Papa.parse(file, {
- header: true,
- complete: ({ data }) => {
- const rows = data.filter(r => r.email).map(r => ({
- name: r.name || r.Name || '',
- email: r.email || r.Email || '',
- password: r.password || r.Password || 'TempPass@123',
- role: (r.role || r.Role || 'member').toLowerCase(),
- }));
- setBulkPreview(rows);
- }
- });
+ setCsvFile(file);
+ setBulkResult(null);
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ const { rows, invalid } = parseCSV(ev.target.result);
+ setCsvRows(rows);
+ setCsvInvalid(invalid);
+ };
+ reader.readAsText(file);
};
const handleBulkImport = async () => {
- if (!bulkPreview.length) return;
+ if (!csvRows.length) return;
setBulkLoading(true);
try {
- const results = await api.bulkUsers(bulkPreview);
- toast(`Created: ${results.created.length}, Errors: ${results.errors.length}`, results.errors.length ? 'default' : 'success');
- setBulkPreview([]);
- setTab('users');
+ const result = await api.bulkUsers(csvRows);
+ setBulkResult(result);
+ setCsvRows([]); setCsvFile(null); setCsvInvalid([]);
+ if (fileRef.current) fileRef.current.value = '';
load();
} catch (e) {
toast(e.message, 'error');
@@ -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 (
e.target === e.currentTarget && onClose()}>
-
+
User Manager
@@ -128,78 +278,22 @@ export default function UserManagerModal({ onClose }) {
- {/* Tabs */}
- setTab('users')}>
- All Users ({users.length})
-
- setTab('create')}>
- + Create User
-
- setTab('bulk')}>
- Bulk Import CSV
-
+ setTab('users')}>All Users ({users.length})
+ setTab('create')}>+ Create User
+ setTab('bulk')}>Bulk Import CSV
- {/* Users list */}
+ {/* Users list — accordion */}
{tab === 'users' && (
<>
-
setSearch(e.target.value)} />
+
setSearch(e.target.value)} />
{loading ? (
) : (
-
+
{filtered.map(u => (
-
-
-
-
-
- {u.display_name || u.name}
- {u.role}
- {u.status !== 'active' && {u.status} }
- {u.is_default_admin ? Default Admin : null}
-
-
{u.email}
- {u.must_change_password ?
⚠ Must change password : null}
-
-
- {/* Actions */}
- {!u.is_default_admin && (
-
- {resetingId === u.id ? (
-
- setResetPw(e.target.value)} />
- handleResetPw(u.id)}>Set
- { setResetingId(null); setResetPw(''); }}>✕
-
- ) : (
- <>
-
setResetingId(u.id)} title="Reset password">
-
- Reset PW
-
-
handleRole(u, e.target.value)}
- className="input"
- style={{ width: 90, padding: '4px 6px', fontSize: 12 }}
- >
- Member
- Admin
-
- {u.status === 'active' ? (
-
handleSuspend(u)}>Suspend
- ) : u.status === 'suspended' ? (
-
handleActivate(u)} style={{ color: 'var(--success)' }}>Activate
- ) : null}
-
handleDelete(u)}>Delete
- >
- )}
-
- )}
-
-
+
))}
)}
@@ -209,20 +303,20 @@ export default function UserManagerModal({ onClose }) {
{/* Create user */}
{tab === 'create' && (
-
-
-
Full Name
-
setForm(p => ({ ...p, name: e.target.value }))} />
+
+
+ Full Name (First Last)
+ setForm(p => ({ ...p, name: e.target.value }))} />
-
-
-
-
Temp Password
-
setForm(p => ({ ...p, password: e.target.value }))} />
+
+
+ Temp Password (blank = default)
+ setForm(p => ({ ...p, password: e.target.value }))} />
Role
@@ -232,8 +326,8 @@ export default function UserManagerModal({ onClose }) {
-
User will be required to change their password on first login.
-
{creating ? 'Creating...' : 'Create User'}
+
User must change password on first login. Duplicate names get a number suffix automatically.
+
{creating ? 'Creating…' : 'Create User'}
)}
@@ -241,44 +335,65 @@ export default function UserManagerModal({ onClose }) {
{tab === 'bulk' && (
-
CSV Format
-
- name,email,password,role{'\n'}
- John Doe,john@example.com,TempPass123,member
-
+
CSV Format
+
name,email,password,role{'\n'}Jane Smith,jane@company.local,,member{'\n'}Bob Jones,bob@company.com,TempPass1,admin
- 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 {userPass} , Role defaults to member. Lines with duplicate emails are skipped. Duplicate names get a number suffix.
-
- Select CSV File
-
-
-
- {bulkPreview.length > 0 && (
- <>
-
-
Preview ({bulkPreview.length} users)
-
- {bulkPreview.slice(0, 10).map((u, i) => (
-
- {u.name}
- {u.email}
- {u.role}
-
- ))}
- {bulkPreview.length > 10 && (
-
- ...and {bulkPreview.length - 10} more
-
- )}
-
-
-
- {bulkLoading ? 'Importing...' : `Import ${bulkPreview.length} Users`}
+
+
+ Select CSV File
+
+
+ {csvFile && (
+
+ {csvFile.name}
+ {csvRows.length > 0 && ({csvRows.length} valid) }
+
+ )}
+ {csvRows.length > 0 && (
+
+ {bulkLoading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length !== 1 ? 's' : ''}`}
- >
+ )}
+
+
+ {csvInvalid.length > 0 && (
+
+
{csvInvalid.length} line{csvInvalid.length !== 1 ? 's' : ''} skipped — invalid format
+
+ {csvInvalid.map((e, i) => (
+
+ {e.line}
+ — {e.reason}
+
+ ))}
+
+
+ )}
+
+ {bulkResult && (
+
+
+ ✓ {bulkResult.created.length} user{bulkResult.created.length !== 1 ? 's' : ''} created successfully
+
+ {bulkResult.skipped.length > 0 && (
+ <>
+
{bulkResult.skipped.length} account{bulkResult.skipped.length !== 1 ? 's' : ''} skipped:
+
+ {bulkResult.skipped.map((s, i) => (
+
+ {s.email}
+ {s.reason}
+
+ ))}
+
+ >
+ )}
+
setBulkResult(null)}>Dismiss
+
)}
)}
diff --git a/frontend/src/components/UserProfilePopup.jsx b/frontend/src/components/UserProfilePopup.jsx
index 942ba9b..03f8a6d 100644
--- a/frontend/src/components/UserProfilePopup.jsx
+++ b/frontend/src/components/UserProfilePopup.jsx
@@ -1,8 +1,14 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import Avatar from './Avatar.jsx';
+import { api } from '../utils/api.js';
+import { useAuth } from '../contexts/AuthContext.jsx';
-export default function UserProfilePopup({ user, anchorEl, onClose }) {
+export default function UserProfilePopup({ user: profileUser, anchorEl, onClose, onDirectMessage }) {
+ const { user: currentUser } = useAuth();
const popupRef = useRef(null);
+ const [starting, setStarting] = useState(false);
+
+ const isSelf = currentUser?.id === profileUser?.id;
useEffect(() => {
const handler = (e) => {
@@ -15,7 +21,6 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
return () => document.removeEventListener('mousedown', handler);
}, [anchorEl, onClose]);
- // Position near the anchor element
useEffect(() => {
if (!popupRef.current || !anchorEl) return;
const anchor = anchorEl.getBoundingClientRect();
@@ -23,20 +28,35 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
const viewportH = window.innerHeight;
const viewportW = window.innerWidth;
- // Default: below and to the right of avatar
let top = anchor.bottom + 8;
let left = anchor.left;
- // Flip up if not enough space below
- if (top + 220 > viewportH) top = anchor.top - 228;
- // Clamp right edge
+ if (top + 260 > viewportH) top = anchor.top - 268;
if (left + 220 > viewportW) left = viewportW - 228;
popup.style.top = `${top}px`;
popup.style.left = `${left}px`;
}, [anchorEl]);
- 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 (
-
+
- {user.display_name || user.name}
+ {profileUser.name}
- {user.role === 'admin' && !user.hide_admin_tag && (
+ {profileUser.role === 'admin' && !profileUser.hide_admin_tag && (
Admin
)}
- {user.about_me && (
+ {profileUser.about_me && (
- {user.about_me}
+ {profileUser.about_me}
)}
+ {!isSelf && onDirectMessage && (
+
{ e.currentTarget.style.background = 'var(--primary)'; e.currentTarget.style.color = 'white'; }}
+ onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--primary)'; }}
+ >
+
+
+
+ {starting ? 'Opening...' : 'Direct Message'}
+
+ )}
);
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index b13b0f5..e0fc9e7 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -28,7 +28,7 @@
--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; }
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"] .message-input-area { background: var(--surface); border-color: var(--border); }
[data-theme="dark"] .message-input-wrap { background: var(--surface-variant); border-color: var(--border); }
+[data-theme="dark"] .msg-input:focus { background: var(--surface-variant); color: var(--text-primary); }
+/* Light mode: focused input goes white so it pops from the grey background */
+[data-theme="light"] .msg-input:focus, :root:not([data-theme="dark"]) .msg-input:focus { background: white; }
[data-theme="dark"] .btn-secondary { border-color: var(--border); color: var(--primary); }
[data-theme="dark"] .btn-secondary:hover { background: var(--primary-light); }
[data-theme="dark"] .search-input { background: var(--surface-variant); color: var(--text-primary); }
@@ -236,6 +239,10 @@ a { color: inherit; text-decoration: none; }
[data-theme="dark"] .footer-menu-item.danger:hover { background: #3a1a1a; }
[data-theme="dark"] .btn-icon { color: var(--text-primary); }
[data-theme="dark"] .btn-icon:hover { background: var(--surface-variant); }
+[data-theme="dark"] .sidebar-header { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .newchat-btn { background: var(--surface-variant); border-color: var(--primary); color: var(--primary); }
+[data-theme="dark"] .newchat-btn:hover { background: var(--primary); color: white; }
+[data-theme="dark"] .newchat-fab { background: var(--primary); }
[data-theme="dark"] .msg-actions { background: var(--surface); border-color: var(--border); }
[data-theme="dark"] .reaction-btn:hover { background: var(--surface-variant); }
[data-theme="dark"] .emoji-picker-wrap { background: var(--surface); border-color: var(--border); }
@@ -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"] .readonly-bar { background: var(--surface); border-color: var(--border); color: var(--text-secondary); }
[data-theme="dark"] .warning-banner { background: #2a1f00; border-color: #6a4a00; color: #ffb74d; }
+
+/* ── About Modal ─────────────────────────────────────── */
+.about-modal {
+ max-width: 420px;
+ text-align: center;
+ position: relative;
+ padding: 32px 28px 24px;
+}
+.about-close {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+}
+.about-hero {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 28px;
+}
+.about-logo {
+ width: 80px;
+ height: 80px;
+ object-fit: contain;
+ margin-bottom: 12px;
+}
+.about-appname {
+ font-size: 26px;
+ font-weight: 800;
+ color: var(--primary);
+ margin: 0 0 4px;
+}
+.about-tagline {
+ font-size: 13px;
+ color: var(--text-tertiary);
+ font-style: italic;
+ margin: 0;
+}
+.about-table {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: visible;
+ margin-bottom: 20px;
+ text-align: left;
+}
+.about-row {
+ display: flex;
+ align-items: flex-start;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border);
+ gap: 12px;
+}
+.about-row:last-child { border-bottom: none; }
+.about-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ min-width: 90px;
+ flex-shrink: 0;
+ padding-top: 1px;
+}
+.about-value {
+ font-size: 14px;
+ color: var(--text-primary);
+ flex: 1;
+ min-width: 0;
+ overflow-wrap: break-word;
+ word-break: normal;
+ white-space: normal;
+ line-height: 1.5;
+}
+.about-mono {
+ font-family: monospace;
+ font-size: 13px;
+}
+.about-link {
+ color: var(--primary);
+ text-decoration: underline;
+}
+.about-link:hover { opacity: 0.8; }
+.about-footer {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ margin: 0;
+}
+[data-theme="dark"] .about-table { border-color: var(--border); }
+[data-theme="dark"] .about-row { border-color: var(--border); }
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 660b8a5..fc7b896 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -16,6 +16,7 @@ if ('serviceWorker' in navigator) {
});
}
+
// Clear badge count when user focuses the app
window.addEventListener('focus', () => {
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
diff --git a/frontend/src/pages/Chat.css b/frontend/src/pages/Chat.css
index 5b8dac0..4d401b3 100644
--- a/frontend/src/pages/Chat.css
+++ b/frontend/src/pages/Chat.css
@@ -1,12 +1,94 @@
.chat-layout {
display: flex;
+ flex-direction: column;
height: 100vh;
+ height: 100dvh;
overflow: hidden;
background: var(--background);
}
+/* Global top bar */
+.global-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ height: 72px;
+ min-height: 72px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ z-index: 20;
+ flex-shrink: 0;
+}
+
+.global-bar-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.global-bar-logo {
+ width: 40px;
+ height: 40px;
+ object-fit: contain;
+ border-radius: 6px;
+ flex-shrink: 0;
+}
+
+.global-bar-title {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--primary);
+}
+
+.global-bar-offline {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: #e53935;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.offline-label {
+ font-size: 13px;
+}
+
+/* Body below global bar */
+.chat-body {
+ display: flex;
+ flex: 1;
+ min-height: 0; /* allows body to shrink when mobile keyboard resizes viewport */
+ overflow: hidden;
+}
+
@media (max-width: 767px) {
.chat-layout {
position: relative;
}
+ .chat-body {
+ overflow: hidden;
+ min-height: 0;
+ }
+ .global-bar {
+ height: 56px;
+ min-height: 56px;
+ }
+}
+
+[data-theme="dark"] .global-bar {
+ background: var(--surface);
+ border-color: var(--border);
+}
+
+.no-chat-selected {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-tertiary);
+ font-size: 14px;
+ gap: 4px;
+ user-select: none;
}
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx
index 512a1ad..529b5bb 100644
--- a/frontend/src/pages/Chat.jsx
+++ b/frontend/src/pages/Chat.jsx
@@ -9,6 +9,8 @@ import ProfileModal from '../components/ProfileModal.jsx';
import UserManagerModal from '../components/UserManagerModal.jsx';
import SettingsModal from '../components/SettingsModal.jsx';
import NewChatModal from '../components/NewChatModal.jsx';
+import GlobalBar from '../components/GlobalBar.jsx';
+import AboutModal from '../components/AboutModal.jsx';
import './Chat.css';
function urlBase64ToUint8Array(base64String) {
@@ -99,7 +101,8 @@ export default function Chat() {
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 => {
if (msg.group_id === activeGroupId) return prev;
const next = new Map(prev);
@@ -110,14 +113,8 @@ export default function Chat() {
const handleNotification = (notif) => {
if (notif.type === 'private_message') {
- // Private message unread is already handled by handleNewMsg above
- // (kept for push notification path when socket is not the source)
- 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;
- });
+ // Badge is already handled by handleNewMsg via message:new socket event.
+ // Nothing to do here for the socket path.
} else {
setNotifications(prev => [notif, ...prev]);
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('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 () => {
socket.off('message:new', handleNewMsg);
socket.off('notification:new', handleNotification);
+ socket.off('group:new', handleGroupNew);
+ socket.off('group:deleted', handleGroupDeleted);
+ socket.off('group:updated', handleGroupUpdated);
};
- }, [socket, toast]);
+ }, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
const selectGroup = (id) => {
setActiveGroupId(id);
@@ -141,6 +178,13 @@ export default function Chat() {
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 = [
...(groups.publicGroups || []),
...(groups.privateGroups || [])
@@ -148,33 +192,42 @@ export default function Chat() {
return (
- {(!isMobile || showSidebar) && (
-
setModal('newchat')}
- onProfile={() => setModal('profile')}
- onUsers={() => setModal('users')}
- onSettings={() => setModal('settings')}
- onGroupsUpdated={loadGroups}
- />
- )}
+ {/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
+
- {(!isMobile || !showSidebar) && (
- setShowSidebar(true) : null}
- onGroupUpdated={loadGroups}
- />
- )}
+
+ {(!isMobile || showSidebar) && (
+ setModal('newchat')}
+ onProfile={() => setModal('profile')}
+ onUsers={() => setModal('users')}
+ onSettings={() => setModal('settings')}
+ onGroupsUpdated={loadGroups}
+ isMobile={isMobile}
+ onAbout={() => setModal('about')}
+ />
+ )}
+
+ {(!isMobile || !showSidebar) && (
+ { setShowSidebar(true); setActiveGroupId(null); } : null}
+ onGroupUpdated={loadGroups}
+ onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
+ />
+ )}
+
{modal === 'profile' && setModal(null)} />}
{modal === 'users' && setModal(null)} />}
{modal === 'settings' && setModal(null)} />}
{modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
+ {modal === 'about' && setModal(null)} />}
);
}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 615db1c..f771e96 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -4,6 +4,17 @@ function getToken() {
return localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
}
+// SQLite datetime('now') returns "YYYY-MM-DD HH:MM:SS" with no timezone marker.
+// Browsers parse bare strings like this as LOCAL time, but the value is actually UTC.
+// Appending 'Z' forces correct UTC interpretation so local display is always right.
+export function parseTS(ts) {
+ if (!ts) return new Date(NaN);
+ // Already has timezone info (contains T and Z/+ or ends in Z) — leave alone
+ if (/Z$|[+-]\d{2}:\d{2}$/.test(ts) || (ts.includes('T') && ts.includes('Z'))) return new Date(ts);
+ // Replace the space separator SQLite uses and append Z
+ return new Date(ts.replace(' ', 'T') + 'Z');
+}
+
async function req(method, path, body, opts = {}) {
const token = getToken();
const headers = {};
@@ -45,11 +56,13 @@ export const api = {
searchUsers: (q) => req('GET', `/users/search?q=${encodeURIComponent(q)}`),
createUser: (body) => req('POST', '/users', body),
bulkUsers: (users) => req('POST', '/users/bulk', { users }),
+ updateName: (id, name) => req('PATCH', `/users/${id}/name`, { name }),
updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }),
resetPassword: (id, password) => req('PATCH', `/users/${id}/reset-password`, { password }),
suspendUser: (id) => req('PATCH', `/users/${id}/suspend`),
activateUser: (id) => req('PATCH', `/users/${id}/activate`),
deleteUser: (id) => req('DELETE', `/users/${id}`),
+ checkDisplayName: (name) => req('GET', `/users/check-display-name?name=${encodeURIComponent(name)}`),
updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag }
uploadAvatar: (file) => {
const form = new FormData(); form.append('avatar', file);