diff --git a/.env.example b/.env.example index 504b8c4..d9c445a 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ TZ=UTC # Copy this file to .env and customize # Image version to run (set by build.sh, or use 'latest') -JAMA_VERSION=0.7.2 +JAMA_VERSION=0.7.4 # Default admin credentials (used on FIRST RUN only) ADMIN_NAME=Admin User diff --git a/backend/package.json b/backend/package.json index 2c9c3e3..9377125 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.7.2", + "version": "0.7.4", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/index.js b/backend/src/index.js index b3ea298..6c76c65 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -21,7 +21,14 @@ const PORT = process.env.PORT || 3000; // Init DB initDb(); seedAdmin(); -getOrCreateSupportGroup(); // Ensure Support group exists +// Ensure Support group exists and all admins are members +const supportGroupId = getOrCreateSupportGroup(); +if (supportGroupId) { + const db = getDb(); + const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all(); + const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)'); + for (const a of admins) insert.run(supportGroupId, a.id); +} // Middleware app.use(cors()); diff --git a/backend/src/routes/about.js b/backend/src/routes/about.js index 6fa2d24..3f77aeb 100644 --- a/backend/src/routes/about.js +++ b/backend/src/routes/about.js @@ -29,6 +29,9 @@ router.get('/', (req, res) => { ...DEFAULTS, ...overrides, version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev', + // Always expose original app identity — not overrideable via about.json or settings + default_app_name: 'jama', + default_logo: '/icons/jama.png', }; // Never expose docker_image — removed from UI diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 79b67ec..791a481 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -3,7 +3,7 @@ const bcrypt = require('bcryptjs'); const multer = require('multer'); const path = require('path'); const router = express.Router(); -const { getDb, addUserToPublicGroups } = require('../models/db'); +const { getDb, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db'); const { authMiddleware, adminMiddleware } = require('../middleware/auth'); const avatarStorage = multer.diskStorage({ @@ -121,6 +121,13 @@ router.post('/', authMiddleware, adminMiddleware, (req, res) => { `).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member'); addUserToPublicGroups(result.lastInsertRowid); + // Admin users are automatically added to the Support group + if (role === 'admin') { + const supportGroupId = getOrCreateSupportGroup(); + if (supportGroupId) { + db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, 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 }); }); @@ -151,8 +158,15 @@ router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => { 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'); + const newRole = u.role === 'admin' ? 'admin' : 'member'; + const r = insertUser.run(resolvedName, email, hash, newRole); addUserToPublicGroups(r.lastInsertRowid); + if (newRole === 'admin') { + const supportGroupId = getOrCreateSupportGroup(); + if (supportGroupId) { + db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, r.lastInsertRowid); + } + } results.created.push(email); } catch (e) { results.skipped.push({ email, reason: e.message }); @@ -184,6 +198,13 @@ router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => { 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); + // If promoted to admin, ensure they're in the Support group + if (role === 'admin') { + const supportGroupId = getOrCreateSupportGroup(); + if (supportGroupId) { + db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, target.id); + } + } res.json({ success: true }); }); diff --git a/build.sh b/build.sh index 5262cf1..c015d3f 100644 --- a/build.sh +++ b/build.sh @@ -11,9 +11,9 @@ # REGISTRY=ghcr.io/yourname ./build.sh 1.2.0 push # REGISTRY=yourdockerhubuser ./build.sh 1.2.0 push # ───────────────────────────────────────────────────────────── -set -euo pipefail +set -euo pipefail -VERSION="${1:-0.7.2}" +VERSION="${1:-0.7.4}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 3c79d38..a733e83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.7.2", + "version": "0.7.4", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/AboutModal.jsx b/frontend/src/components/AboutModal.jsx index 426af5e..4657151 100644 --- a/frontend/src/components/AboutModal.jsx +++ b/frontend/src/components/AboutModal.jsx @@ -22,20 +22,19 @@ function BuiltWithValue({ value }) { } 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 || ''; + // Always use the original app identity — not the user-customised settings name/logo + const appName = about?.default_app_name || 'jama'; + const logoSrc = about?.default_logo || '/icons/jama.png'; + const version = about?.version || ''; const a = about || {}; const rows = [ @@ -55,7 +54,7 @@ export default function AboutModal({ onClose }) {
- jama + {appName}

{appName}

just another messaging app

diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 67e1071..8f70f18 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -43,7 +43,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess }, []); useEffect(() => { - if (!group) { setMessages([]); return; } + if (!group) { setMessages([]); setPinnedMsgIds(new Set()); setPinCount(0); return; } setMessages([]); setHasMore(false); setLoading(true); @@ -55,8 +55,40 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess }) .catch(e => toast(e.message, 'error')) .finally(() => setLoading(false)); + // Load pinned messages for DMs + if (group.is_direct) { + api.getPinnedMessages(group.id) + .then(({ pinned, count }) => { + setPinnedMsgIds(new Set(pinned.map(p => p.id))); + setPinCount(count); + }) + .catch(() => {}); + } else { + setPinnedMsgIds(new Set()); + setPinCount(0); + } }, [group?.id]); + const handlePinMessage = async (msgId) => { + try { + const { count } = await api.pinMessage(msgId); + setPinnedMsgIds(prev => new Set([...prev, msgId])); + setPinCount(count); + } catch (e) { + toast(e.message || 'Could not pin message', 'error'); + } + }; + + const handleUnpinMessage = async (msgId) => { + try { + const { count } = await api.unpinMessage(msgId); + setPinnedMsgIds(prev => { const n = new Set(prev); n.delete(msgId); return n; }); + setPinCount(count); + } catch (e) { + toast(e.message || 'Could not unpin message', 'error'); + } + }; + // Socket events useEffect(() => { if (!socket || !group) return; diff --git a/frontend/src/components/Message.jsx b/frontend/src/components/Message.jsx index c370dc5..1fa1372 100644 --- a/frontend/src/components/Message.jsx +++ b/frontend/src/components/Message.jsx @@ -62,6 +62,60 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl msg.group_owner_id === currentUser.id ); + // Close emoji picker when clicking outside + useEffect(() => { + if (!showEmojiPicker) return; + const handler = (e) => { + if (pickerRef.current && !pickerRef.current.contains(e.target)) { + setShowEmojiPicker(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showEmojiPicker]); + + // Close options menu on outside click + useEffect(() => { + if (!showOptionsMenu) return; + const close = (e) => { + if (optionsMenuRef.current && !optionsMenuRef.current.contains(e.target)) { + setShowOptionsMenu(false); + } + }; + document.addEventListener('mousedown', close); + document.addEventListener('touchstart', close); + return () => { + document.removeEventListener('mousedown', close); + document.removeEventListener('touchstart', close); + }; + }, [showOptionsMenu]); + + const handleReact = (emoji) => { + onReact(msg.id, emoji); + setShowEmojiPicker(false); + }; + + const handleCopy = () => { + if (!msg.content) return; + navigator.clipboard.writeText(msg.content).catch(() => {}); + }; + + const handleTogglePicker = () => { + if (!showEmojiPicker && wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect(); + setPickerOpensDown(rect.top < 400); + } + setShowEmojiPicker(p => !p); + }; + + // Long press for mobile action menu + const handleTouchStart = () => { + longPressTimer.current = setTimeout(() => setShowOptionsMenu(true), 500); + }; + const handleTouchEnd = () => { + if (longPressTimer.current) clearTimeout(longPressTimer.current); + }; + // Deleted messages are filtered out by ChatWindow, but guard here too if (isDeleted) return null; @@ -85,61 +139,6 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl if (r.user_id === currentUser.id) reactionMap[r.emoji].hasMe = true; } - // Close emoji picker when clicking outside - useEffect(() => { - if (!showEmojiPicker) return; - const handler = (e) => { - if (pickerRef.current && !pickerRef.current.contains(e.target)) { - setShowEmojiPicker(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [showEmojiPicker]); - - const handleReact = (emoji) => { - onReact(msg.id, emoji); - setShowEmojiPicker(false); - }; - - const handleCopy = () => { - if (!msg.content) return; - navigator.clipboard.writeText(msg.content).catch(() => {}); - }; - - const handleTogglePicker = () => { - if (!showEmojiPicker && wrapperRef.current) { - // If the message is in the top 400px of viewport, open picker downward - const rect = wrapperRef.current.getBoundingClientRect(); - setPickerOpensDown(rect.top < 400); - } - setShowEmojiPicker(p => !p); - }; - - // Long press for mobile action menu - const handleTouchStart = () => { - longPressTimer.current = setTimeout(() => setShowOptionsMenu(true), 500); - }; - const handleTouchEnd = () => { - if (longPressTimer.current) clearTimeout(longPressTimer.current); - }; - - // Close options menu on outside click - useEffect(() => { - if (!showOptionsMenu) return; - const close = (e) => { - if (optionsMenuRef.current && !optionsMenuRef.current.contains(e.target)) { - setShowOptionsMenu(false); - } - }; - document.addEventListener('mousedown', close); - document.addEventListener('touchstart', close); - return () => { - document.removeEventListener('mousedown', close); - document.removeEventListener('touchstart', close); - }; - }, [showOptionsMenu]); - const msgUser = { id: msg.user_id, name: msg.user_name, diff --git a/frontend/src/components/UserManagerModal.jsx b/frontend/src/components/UserManagerModal.jsx index 33038fc..2eb3e23 100644 --- a/frontend/src/components/UserManagerModal.jsx +++ b/frontend/src/components/UserManagerModal.jsx @@ -327,7 +327,7 @@ export default function UserManagerModal({ onClose }) {
- + setForm(p => ({ ...p, password: e.target.value }))} />