v0.5.0 UI and new message rules
This commit is contained in:
@@ -7,7 +7,7 @@ TZ=UTC
|
|||||||
# Copy this file to .env and customize
|
# Copy this file to .env and customize
|
||||||
|
|
||||||
# Image version to run (set by build.sh, or use 'latest')
|
# Image version to run (set by build.sh, or use 'latest')
|
||||||
JAMA_VERSION=0.4.0
|
JAMA_VERSION=0.5.0
|
||||||
|
|
||||||
# Default admin credentials (used on FIRST RUN only)
|
# Default admin credentials (used on FIRST RUN only)
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -182,6 +182,19 @@ function initDb() {
|
|||||||
console.log('[DB] Migration: added direct_peer2_id column');
|
console.log('[DB] Migration: added direct_peer2_id column');
|
||||||
} catch (e) { /* column already exists */ }
|
} 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');
|
console.log('[DB] Schema initialized');
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,11 +91,17 @@ router.get('/', authMiddleware, (req, res) => {
|
|||||||
if (otherUserId) {
|
if (otherUserId) {
|
||||||
const other = db.prepare('SELECT display_name, name FROM users WHERE id = ?').get(otherUserId);
|
const other = db.prepare('SELECT display_name, name FROM users WHERE id = ?').get(otherUserId);
|
||||||
if (other) {
|
if (other) {
|
||||||
g.peer_real_name = other.name; // always the real name for sidebar title
|
g.peer_real_name = other.name;
|
||||||
g.name = other.display_name || other.name; // display name for chat header
|
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;
|
return g;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,6 +161,31 @@ router.post('/', authMiddleware, (req, res) => {
|
|||||||
return res.json({ group });
|
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(`
|
const result = db.prepare(`
|
||||||
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct)
|
INSERT INTO groups (name, type, owner_id, is_readonly, is_direct)
|
||||||
VALUES (?, ?, ?, ?, 0)
|
VALUES (?, ?, ?, ?, 0)
|
||||||
@@ -342,5 +373,28 @@ router.delete('/:id', authMiddleware, (req, res) => {
|
|||||||
res.json({ success: true });
|
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;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.4.0}"
|
VERSION="${1:-0.5.0}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
|||||||
const [newName, setNewName] = useState(group.name);
|
const [newName, setNewName] = useState(group.name);
|
||||||
const [addSearch, setAddSearch] = useState('');
|
const [addSearch, setAddSearch] = useState('');
|
||||||
const [addResults, setAddResults] = useState([]);
|
const [addResults, setAddResults] = useState([]);
|
||||||
|
const [customName, setCustomName] = useState(group.owner_name_original ? group.name : '');
|
||||||
|
const [savingCustom, setSavingCustom] = useState(false);
|
||||||
|
|
||||||
const isDirect = !!group.is_direct;
|
const isDirect = !!group.is_direct;
|
||||||
const isOwner = group.owner_id === user.id;
|
const isOwner = group.owner_id === user.id;
|
||||||
@@ -25,6 +27,19 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
|||||||
}
|
}
|
||||||
}, [group.id]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (addSearch) {
|
if (addSearch) {
|
||||||
api.searchUsers(addSearch).then(({ users }) => setAddResults(users)).catch(() => {});
|
api.searchUsers(addSearch).then(({ users }) => setAddResults(users)).catch(() => {});
|
||||||
@@ -115,7 +130,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
|||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input className="input flex-1" value={newName} onChange={e => setNewName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleRename()} />
|
<input className="input flex-1" value={newName} onChange={e => setNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleRename()} />
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleRename}>Save</button>
|
<button className="btn btn-primary btn-sm" onClick={handleRename}>Save</button>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
|
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +152,31 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom name — any user can set their own display name for this group */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 6 }}>
|
||||||
|
Your custom name <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(only visible to you)</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={customName}
|
||||||
|
onChange={e => setCustomName(e.target.value)}
|
||||||
|
placeholder={group.owner_name_original || group.name}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleCustomName()}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleCustomName} disabled={savingCustom}>
|
||||||
|
{customName.trim() ? 'Save' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{group.owner_name_original && (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||||||
|
Showing as: <strong>{customName.trim() || group.owner_name_original}</strong>
|
||||||
|
{customName.trim() && <span> ({group.owner_name_original})</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Members — shown for private non-direct groups */}
|
{/* Members — shown for private non-direct groups */}
|
||||||
{group.type === 'private' && !isDirect && (
|
{group.type === 'private' && !isDirect && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -57,8 +57,12 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { group } = await api.createGroup(payload);
|
const { group, duplicate } = await api.createGroup(payload);
|
||||||
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
|
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);
|
onCreated(group);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
@@ -89,8 +93,8 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Message Name — hidden for direct (1-user) messages */}
|
{/* Message Name — only shown when needed: public always, private only when 2+ members selected */}
|
||||||
{!isDirect && (
|
{(tab === 'public' || (tab === 'private' && selected.length > 1)) && (
|
||||||
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
|
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label>
|
||||||
<input
|
<input
|
||||||
@@ -98,7 +102,6 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
placeholder={namePlaceholder}
|
placeholder={namePlaceholder}
|
||||||
autoFocus={tab === 'public'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -118,7 +121,7 @@ export default function NewChatModal({ onClose, onCreated }) {
|
|||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{isDirect ? 'Direct Message with' : 'Add Members'}
|
{isDirect ? 'Direct Message with' : 'Add Members'}
|
||||||
</label>
|
</label>
|
||||||
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} autoFocus />
|
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected.length > 0 && (
|
{selected.length > 0 && (
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function SupportModal({ onClose }) {
|
|||||||
placeholder="Jane Smith"
|
placeholder="Jane Smith"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
autoFocus
|
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function UserRow({ u, onUpdated }) {
|
|||||||
value={nameVal}
|
value={nameVal}
|
||||||
onChange={e => setNameVal(e.target.value)}
|
onChange={e => setNameVal(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditName(false); setNameVal(u.name); } }}
|
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditName(false); setNameVal(u.name); } }}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleSaveName}>Save</button>
|
<button className="btn btn-primary btn-sm" onClick={handleSaveName}>Save</button>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => { setEditName(false); setNameVal(u.name); }}>✕</button>
|
<button className="btn btn-secondary btn-sm" onClick={() => { setEditName(false); setNameVal(u.name); }}>✕</button>
|
||||||
@@ -157,7 +157,7 @@ function UserRow({ u, onUpdated }) {
|
|||||||
value={resetPw}
|
value={resetPw}
|
||||||
onChange={e => setResetPw(e.target.value)}
|
onChange={e => setResetPw(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleResetPw(); if (e.key === 'Escape') { setShowReset(false); setResetPw(''); } }}
|
onKeyDown={e => { if (e.key === 'Enter') handleResetPw(); if (e.key === 'Escape') { setShowReset(false); setResetPw(''); } }}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleResetPw}>Set</button>
|
<button className="btn btn-primary btn-sm" onClick={handleResetPw}>Set</button>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => { setShowReset(false); setResetPw(''); }}>✕</button>
|
<button className="btn btn-secondary btn-sm" onClick={() => { setShowReset(false); setResetPw(''); }}>✕</button>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const api = {
|
|||||||
getGroups: () => req('GET', '/groups'),
|
getGroups: () => req('GET', '/groups'),
|
||||||
createGroup: (body) => req('POST', '/groups', body),
|
createGroup: (body) => req('POST', '/groups', body),
|
||||||
renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }),
|
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`),
|
getMembers: (id) => req('GET', `/groups/${id}/members`),
|
||||||
addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }),
|
addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }),
|
||||||
removeMember: (groupId, userId) => req('DELETE', `/groups/${groupId}/members/${userId}`),
|
removeMember: (groupId, userId) => req('DELETE', `/groups/${groupId}/members/${userId}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user