diff --git a/backend/src/models/migrations/012_composite_avatar.sql b/backend/src/models/migrations/012_composite_avatar.sql new file mode 100644 index 0000000..f4d4fa9 --- /dev/null +++ b/backend/src/models/migrations/012_composite_avatar.sql @@ -0,0 +1,5 @@ +-- Migration 012: Add composite_members to groups for private group avatar composites +-- Stores up to 4 member previews (id, name, avatar) as a JSONB snapshot. +-- Only set for non-managed, non-direct private groups with 3+ members. +-- Updated only when a member is added and pre-add membership count was ≤3. +ALTER TABLE groups ADD COLUMN IF NOT EXISTS composite_members JSONB; diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index ce877fb..f38f634 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -13,6 +13,21 @@ function deleteImageFile(imageUrl) { // Schema-aware room name helper const R = (schema, type, id) => `${schema}:${type}:${id}`; +// Compute and store composite_members for a non-managed private group. +// Captures up to 4 current members (excluding deleted users), ordered by name. +async function computeAndStoreComposite(schema, groupId) { + const members = await query(schema, + `SELECT u.id, u.name, u.avatar FROM group_members gm + JOIN users u ON gm.user_id = u.id + WHERE gm.group_id = $1 AND u.name != 'Deleted User' + ORDER BY u.name LIMIT 4`, + [groupId] + ); + await exec(schema, 'UPDATE groups SET composite_members=$1 WHERE id=$2', + [JSON.stringify(members), groupId] + ); +} + module.exports = (io) => { async function emitGroupNew(schema, io, groupId) { @@ -202,6 +217,11 @@ router.post('/', authMiddleware, async (req, res) => { await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, uid]); } } + // Generate composite avatar for non-managed private groups with 3+ members + const totalCount = await queryOne(req.schema, 'SELECT COUNT(*) AS cnt FROM group_members WHERE group_id=$1', [groupId]); + if (parseInt(totalCount.cnt) >= 3) { + await computeAndStoreComposite(req.schema, groupId); + } } await emitGroupNew(req.schema, io, groupId); res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) }); @@ -246,6 +266,8 @@ router.post('/:id/members', authMiddleware, async (req, res) => { if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can add members' }); const targetUser = await queryOne(req.schema, 'SELECT is_default_admin FROM users WHERE id=$1', [userId]); if (targetUser?.is_default_admin) return res.status(400).json({ error: 'Default admin cannot be added to private groups' }); + // Capture pre-add count to decide if composite should regenerate + const preAddCount = await queryOne(req.schema, 'SELECT COUNT(*) AS cnt FROM group_members WHERE group_id=$1', [group.id]); await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [group.id, userId]); const addedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]); const addedName = addedUser?.display_name || addedUser?.name || 'Unknown'; @@ -259,6 +281,14 @@ router.post('/:id/members', authMiddleware, async (req, res) => { ); sysMsg.reactions = []; io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg); + // Regenerate composite if pre-add count was ≤3 and group is non-managed private + if (!group.is_managed && !group.is_direct && parseInt(preAddCount.cnt) <= 3) { + const newTotal = parseInt(preAddCount.cnt) + 1; + if (newTotal >= 3) { + await computeAndStoreComposite(req.schema, group.id); + } + await emitGroupUpdated(req.schema, io, group.id); + } io.in(R(req.schema,'user',userId)).socketsJoin(R(req.schema,'group',group.id)); io.to(R(req.schema,'user',userId)).emit('group:new', { group }); res.json({ success: true }); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 63ef527..b074585 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -33,15 +33,15 @@ const COMPOSITE_LAYOUTS = { ], }; -function GroupAvatarComposite({ memberPreviews, fallbackLabel, fallbackColor }) { +function GroupAvatarComposite({ memberPreviews }) { const members = (memberPreviews || []).slice(0, 4); const n = members.length; const positions = COMPOSITE_LAYOUTS[n]; if (!positions) { return ( -
- {fallbackLabel} +
+ ?
); } @@ -165,9 +165,11 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica {(group.peer_real_name || group.name)[0]?.toUpperCase()}
) : group.is_managed && group.is_multi_group ? ( - +
MG
) : group.is_managed ? ( - +
UG
+ ) : group.composite_members?.length > 0 ? ( + ) : (
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()} @@ -231,7 +233,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica )} {groupMessagesMode && privateFiltered.length > 0 && (
-
PRIVATE GROUP MESSAGES
+
USER GROUP MESSAGES
{privateFiltered.map(g => )}
)}