priviate group avatars update

This commit is contained in:
2026-03-28 12:02:57 -04:00
parent d7790bb7ef
commit eb3e45d88f
3 changed files with 43 additions and 6 deletions

View File

@@ -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;

View File

@@ -13,6 +13,21 @@ function deleteImageFile(imageUrl) {
// Schema-aware room name helper // Schema-aware room name helper
const R = (schema, type, id) => `${schema}:${type}:${id}`; 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) => { module.exports = (io) => {
async function emitGroupNew(schema, io, groupId) { 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]); 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); await emitGroupNew(req.schema, io, groupId);
res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [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' }); 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]); 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' }); 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]); 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 addedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown'; const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
@@ -259,6 +281,14 @@ router.post('/:id/members', authMiddleware, async (req, res) => {
); );
sysMsg.reactions = []; sysMsg.reactions = [];
io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg); 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.in(R(req.schema,'user',userId)).socketsJoin(R(req.schema,'group',group.id));
io.to(R(req.schema,'user',userId)).emit('group:new', { group }); io.to(R(req.schema,'user',userId)).emit('group:new', { group });
res.json({ success: true }); res.json({ success: true });

View File

@@ -33,15 +33,15 @@ const COMPOSITE_LAYOUTS = {
], ],
}; };
function GroupAvatarComposite({ memberPreviews, fallbackLabel, fallbackColor }) { function GroupAvatarComposite({ memberPreviews }) {
const members = (memberPreviews || []).slice(0, 4); const members = (memberPreviews || []).slice(0, 4);
const n = members.length; const n = members.length;
const positions = COMPOSITE_LAYOUTS[n]; const positions = COMPOSITE_LAYOUTS[n];
if (!positions) { if (!positions) {
return ( return (
<div className="group-icon" style={{ background: fallbackColor, borderRadius: 8, fontSize: 11, fontWeight: 700 }}> <div className="group-icon" style={{ background: '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>
{fallbackLabel} ?
</div> </div>
); );
} }
@@ -165,9 +165,11 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{(group.peer_real_name || group.name)[0]?.toUpperCase()} {(group.peer_real_name || group.name)[0]?.toUpperCase()}
</div> </div>
) : group.is_managed && group.is_multi_group ? ( ) : group.is_managed && group.is_multi_group ? (
<GroupAvatarComposite memberPreviews={group.member_previews} fallbackLabel="MG" fallbackColor={settings.color_avatar_dm || '#a142f4'} /> <div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>MG</div>
) : group.is_managed ? ( ) : group.is_managed ? (
<GroupAvatarComposite memberPreviews={group.member_previews} fallbackLabel="UG" fallbackColor={settings.color_avatar_dm || '#a142f4'} /> <div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>UG</div>
) : group.composite_members?.length > 0 ? (
<GroupAvatarComposite memberPreviews={group.composite_members} />
) : ( ) : (
<div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#a142f4') }}> <div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#a142f4') }}>
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()} {group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
@@ -231,7 +233,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
)} )}
{groupMessagesMode && privateFiltered.length > 0 && ( {groupMessagesMode && privateFiltered.length > 0 && (
<div className="group-section"> <div className="group-section">
<div className="section-label">PRIVATE GROUP MESSAGES</div> <div className="section-label">USER GROUP MESSAGES</div>
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)} {privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
</div> </div>
)} )}