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()}> +
+ + +
+ jama +

{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'}
-
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} + {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 && (
))}
- {canManage && (
setAddSearch(e.target.value)} /> {addResults.length > 0 && addSearch && ( -
+
{addResults.filter(u => !members.find(m => m.id === u.id)).map(u => ( - ))}
@@ -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 && ( + + )} + {/* Regular private: leave if not owner */} + {!isDirect && group.type === 'private' && !isOwner && ( )} - {isAdmin && group.type === 'private' && group.owner_id !== user.id && ( - + {/* Admin take ownership (non-direct only) */} + {!isDirect && isAdmin && group.type === 'private' && !isOwner && ( + )} - {(isOwner || (isAdmin && group.type === 'public')) && !group.is_default && ( - + {/* Delete */} + {(canDeleteDirect || canDeleteRegular) && ( + )}
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 + {msg.content && ( + + )} {canDelete && (
)} -
+
{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 )}
- + + {/* + button — attach menu trigger */} +
+ + + {showAttachMenu && ( +
+ {/* Photo from library */} + + {/* Camera — mobile only */} + {isMobile() && ( + + )} + {/* Emoji */} + +
+ )} +
+ + {/* Hidden file inputs */} + + + {/* Emoji picker popover */} + {showEmojiPicker && ( +
+ +
+ )}