diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index d012506..ce877fb 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -56,7 +56,14 @@ router.get('/', authMiddleware, async (req, res) => { (SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count, (SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message, (SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at, - (SELECT m.user_id FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_user_id + (SELECT m.user_id FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_user_id, + (SELECT json_agg(t) FROM ( + SELECT u2.id, u2.name, u2.avatar + FROM group_members gm2 + JOIN users u2 ON gm2.user_id = u2.id + WHERE gm2.group_id = g.id AND u2.name != 'Deleted User' + ORDER BY u2.name LIMIT 4 + ) t) AS member_previews FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1 LEFT JOIN users u ON g.owner_id=u.id WHERE g.type='private' ORDER BY last_message_at DESC NULLS LAST diff --git a/frontend/src/components/ImageLightbox.jsx b/frontend/src/components/ImageLightbox.jsx index 8d011af..adf0918 100644 --- a/frontend/src/components/ImageLightbox.jsx +++ b/frontend/src/components/ImageLightbox.jsx @@ -4,16 +4,31 @@ import { createPortal } from 'react-dom'; export default function ImageLightbox({ src, onClose }) { const overlayRef = useRef(null); - // Close on Escape; signal global pinch handler to stand down while open + // Close on Escape; enable native pinch-zoom on the image while open useEffect(() => { const handler = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handler); document.body.style.overflow = 'hidden'; + + // Signal the global font-scale pinch handler in main.jsx to stand down document.documentElement.dataset.lightboxOpen = '1'; + + // Enable native browser pinch-to-zoom by removing the scale restrictions. + // The original content is restored exactly on close. + const viewport = document.querySelector('meta[name="viewport"]'); + const originalContent = viewport?.content ?? ''; + if (viewport) { + viewport.content = originalContent + .replace(/,?\s*maximum-scale=[^,]*/g, '') + .replace(/,?\s*user-scalable=[^,]*/g, '') + .trim(); + } + return () => { window.removeEventListener('keydown', handler); document.body.style.overflow = ''; delete document.documentElement.dataset.lightboxOpen; + if (viewport) viewport.content = originalContent; }; }, [onClose]); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 553d692..63ef527 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -13,6 +13,73 @@ function nameToColor(name) { return AVATAR_COLORS[(name || '').charCodeAt(0) % AVATAR_COLORS.length]; } +// Layouts for composite avatars inside a 44×44 circle (all values in px) +const COMPOSITE_LAYOUTS = { + 1: [{ top: 4, left: 4, size: 36 }], + 2: [ + { top: 11, left: 1, size: 21 }, + { top: 11, right: 1, size: 21 }, + ], + 3: [ + { top: 2, left: 3, size: 19 }, + { top: 2, right: 3, size: 19 }, + { bottom: 2, left: 12, size: 19 }, + ], + 4: [ + { top: 1, left: 1, size: 20 }, + { top: 1, right: 1, size: 20 }, + { bottom: 1, left: 1, size: 20 }, + { bottom: 1, right: 1, size: 20 }, + ], +}; + +function GroupAvatarComposite({ memberPreviews, fallbackLabel, fallbackColor }) { + const members = (memberPreviews || []).slice(0, 4); + const n = members.length; + const positions = COMPOSITE_LAYOUTS[n]; + + if (!positions) { + return ( +
+ {fallbackLabel} +
+ ); + } + + return ( +
+ {members.map((m, i) => { + const pos = positions[i]; + const base = { + position: 'absolute', + width: pos.size, + height: pos.size, + borderRadius: '50%', + ...(pos.top !== undefined ? { top: pos.top } : {}), + ...(pos.bottom !== undefined ? { bottom: pos.bottom } : {}), + ...(pos.left !== undefined ? { left: pos.left } : {}), + ...(pos.right !== undefined ? { right: pos.right } : {}), + overflow: 'hidden', + flexShrink: 0, + }; + if (m.avatar) { + return {m.name}; + } + return ( +
+ {(m.name || '')[0]?.toUpperCase()} +
+ ); + })} +
+ ); +} + function useAppSettings() { const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '', color_avatar_public: '', color_avatar_dm: '' }); const fetchSettings = () => { @@ -98,9 +165,9 @@ 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.type === 'public' ? '#' : group.name[0]?.toUpperCase()}