diff --git a/.env.example b/.env.example index af0270b..3372668 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.4.0 +JAMA_VERSION=0.5.0 # Default admin credentials (used on FIRST RUN only) ADMIN_NAME=Admin User diff --git a/backend/package.json b/backend/package.json index b7efe01..ff08cf6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.4.0", + "version": "0.5.0", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/db.js b/backend/src/models/db.js index daeecf0..b628317 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -182,6 +182,19 @@ function initDb() { console.log('[DB] Migration: added direct_peer2_id column'); } catch (e) { /* column already exists */ } + // Migration: user-customised group display names (per-user, per-group) + try { + db.exec(` + CREATE TABLE IF NOT EXISTS user_group_names ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (user_id, group_id) + ) + `); + console.log('[DB] Migration: user_group_names table ready'); + } catch (e) { console.error('[DB] user_group_names migration error:', e.message); } + console.log('[DB] Schema initialized'); return db; } diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index 5e099c8..49ccc1e 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -91,11 +91,17 @@ router.get('/', authMiddleware, (req, res) => { if (otherUserId) { const other = db.prepare('SELECT display_name, name FROM users WHERE id = ?').get(otherUserId); if (other) { - g.peer_real_name = other.name; // always the real name for sidebar title - g.name = other.display_name || other.name; // display name for chat header + g.peer_real_name = other.name; + g.name = other.display_name || other.name; } } } + // Apply user's custom group name if set + const custom = db.prepare('SELECT name FROM user_group_names WHERE user_id = ? AND group_id = ?').get(userId, g.id); + if (custom) { + g.owner_name_original = g.name; // original name shown in brackets in GroupInfoModal + g.name = custom.name; + } return g; }); @@ -155,6 +161,31 @@ router.post('/', authMiddleware, (req, res) => { return res.json({ group }); } + // For private groups: check if exact same set of members already exists in a group + if ((type === 'private' || !type) && !isDirect && memberIds && memberIds.length > 0) { + const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a, b) => a - b); + const count = allMemberIds.length; + + // Find all private non-direct groups where the creator is a member + const candidates = db.prepare(` + SELECT g.id FROM groups g + JOIN group_members gm ON gm.group_id = g.id AND gm.user_id = ? + WHERE g.type = 'private' AND g.is_direct = 0 + `).all(req.user.id); + + for (const candidate of candidates) { + const members = db.prepare( + 'SELECT user_id FROM group_members WHERE group_id = ? ORDER BY user_id' + ).all(candidate.id).map(r => r.user_id); + if (members.length === count && + members.every((id, i) => id === allMemberIds[i])) { + // Exact duplicate found — return the existing group + const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(candidate.id); + return res.json({ group, duplicate: true }); + } + } + } + const result = db.prepare(` INSERT INTO groups (name, type, owner_id, is_readonly, is_direct) VALUES (?, ?, ?, ?, 0) @@ -342,5 +373,28 @@ router.delete('/:id', authMiddleware, (req, res) => { res.json({ success: true }); }); + +// Set or update user's custom name for a group +router.patch('/:id/custom-name', authMiddleware, (req, res) => { + const db = getDb(); + const groupId = parseInt(req.params.id); + const userId = req.user.id; + const { name } = req.body; + + if (!name || !name.trim()) { + // Empty name = remove custom name (revert to owner name) + db.prepare('DELETE FROM user_group_names WHERE user_id = ? AND group_id = ?').run(userId, groupId); + return res.json({ success: true, name: null }); + } + + db.prepare(` + INSERT INTO user_group_names (user_id, group_id, name) + VALUES (?, ?, ?) + ON CONFLICT(user_id, group_id) DO UPDATE SET name = excluded.name + `).run(userId, groupId, name.trim()); + + res.json({ success: true, name: name.trim() }); +}); + return router; }; diff --git a/build.sh b/build.sh index b3d46f4..bdd566d 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.4.0}" +VERSION="${1:-0.5.0}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 7d997a2..a47ae25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.4.0", + "version": "0.5.0", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/GroupInfoModal.jsx b/frontend/src/components/GroupInfoModal.jsx index 4513803..665e468 100644 --- a/frontend/src/components/GroupInfoModal.jsx +++ b/frontend/src/components/GroupInfoModal.jsx @@ -12,6 +12,8 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) { const [newName, setNewName] = useState(group.name); const [addSearch, setAddSearch] = useState(''); const [addResults, setAddResults] = useState([]); + const [customName, setCustomName] = useState(group.owner_name_original ? group.name : ''); + const [savingCustom, setSavingCustom] = useState(false); const isDirect = !!group.is_direct; const isOwner = group.owner_id === user.id; @@ -25,6 +27,19 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) { } }, [group.id]); + const handleCustomName = async () => { + setSavingCustom(true); + try { + await api.setCustomGroupName(group.id, customName.trim()); + toast(customName.trim() ? 'Custom name saved' : 'Custom name removed', 'success'); + onUpdated(); + } catch (e) { + toast(e.message, 'error'); + } finally { + setSavingCustom(false); + } + }; + useEffect(() => { if (addSearch) { api.searchUsers(addSearch).then(({ users }) => setAddResults(users)).catch(() => {}); @@ -115,7 +130,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
{editing ? (
- setNewName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleRename()} /> + setNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleRename()} />
@@ -137,6 +152,31 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
+ {/* Custom name — any user can set their own display name for this group */} +
+ +
+ setCustomName(e.target.value)} + placeholder={group.owner_name_original || group.name} + onKeyDown={e => e.key === 'Enter' && handleCustomName()} + /> + +
+ {group.owner_name_original && ( +

+ Showing as: {customName.trim() || group.owner_name_original} + {customName.trim() && ({group.owner_name_original})} +

+ )} +
+ {/* Members — shown for private non-direct groups */} {group.type === 'private' && !isDirect && (
diff --git a/frontend/src/components/NewChatModal.jsx b/frontend/src/components/NewChatModal.jsx index 74c4b4d..7e53f0b 100644 --- a/frontend/src/components/NewChatModal.jsx +++ b/frontend/src/components/NewChatModal.jsx @@ -57,8 +57,12 @@ export default function NewChatModal({ onClose, onCreated }) { }; } - const { group } = await api.createGroup(payload); - toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success'); + const { group, duplicate } = await api.createGroup(payload); + if (duplicate) { + toast('A group with these members already exists — opening it now.', 'info'); + } else { + toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success'); + } onCreated(group); } catch (e) { toast(e.message, 'error'); @@ -89,8 +93,8 @@ export default function NewChatModal({ onClose, onCreated }) {
)} - {/* Message Name — hidden for direct (1-user) messages */} - {!isDirect && ( + {/* Message Name — only shown when needed: public always, private only when 2+ members selected */} + {(tab === 'public' || (tab === 'private' && selected.length > 1)) && (
setName(e.target.value)} placeholder={namePlaceholder} - autoFocus={tab === 'public'} />
)} @@ -118,7 +121,7 @@ export default function NewChatModal({ onClose, onCreated }) { - setSearch(e.target.value)} autoFocus /> + setSearch(e.target.value)} /> {selected.length > 0 && ( diff --git a/frontend/src/components/SupportModal.jsx b/frontend/src/components/SupportModal.jsx index 4ffe290..76154d6 100644 --- a/frontend/src/components/SupportModal.jsx +++ b/frontend/src/components/SupportModal.jsx @@ -101,7 +101,7 @@ export default function SupportModal({ onClose }) { placeholder="Jane Smith" value={name} onChange={e => setName(e.target.value)} - autoFocus + maxLength={100} /> diff --git a/frontend/src/components/UserManagerModal.jsx b/frontend/src/components/UserManagerModal.jsx index 15d7899..386d8f4 100644 --- a/frontend/src/components/UserManagerModal.jsx +++ b/frontend/src/components/UserManagerModal.jsx @@ -115,7 +115,7 @@ function UserRow({ u, onUpdated }) { value={nameVal} onChange={e => setNameVal(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditName(false); setNameVal(u.name); } }} - autoFocus + /> @@ -157,7 +157,7 @@ function UserRow({ u, onUpdated }) { value={resetPw} onChange={e => setResetPw(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleResetPw(); if (e.key === 'Escape') { setShowReset(false); setResetPw(''); } }} - autoFocus + /> diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index cae4fd8..99dfdc9 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -73,6 +73,7 @@ export const api = { getGroups: () => req('GET', '/groups'), createGroup: (body) => req('POST', '/groups', body), renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }), + setCustomGroupName: (id, name) => req('PATCH', `/groups/${id}/custom-name`, { name }), getMembers: (id) => req('GET', `/groups/${id}/members`), addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }), removeMember: (groupId, userId) => req('DELETE', `/groups/${groupId}/members/${userId}`),