diff --git a/backend/package.json b/backend/package.json index b3bb960..66a8b60 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.32", + "version": "0.12.33", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index f38f634..cdfd8e5 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -281,11 +281,15 @@ 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); + // For non-managed private groups, always notify existing members of the updated group, + // and regenerate composite when pre-add count was ≤3 and new total reaches ≥3. + if (!group.is_managed && !group.is_direct) { + const preCount = parseInt(preAddCount.cnt); + if (preCount <= 3) { + const newTotal = preCount + 1; + if (newTotal >= 3) { + await computeAndStoreComposite(req.schema, group.id); + } } await emitGroupUpdated(req.schema, io, group.id); } diff --git a/build.sh b/build.sh index 3f23a33..4557832 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.32}" +VERSION="${1:-0.12.33}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 6833a27..a78420f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.32", + "version": "0.12.33", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index c746708..38a4240 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -14,6 +14,55 @@ function nameToColor(name) { return AVATAR_COLORS[(name || '').charCodeAt(0) % AVATAR_COLORS.length]; } +// Composite avatar layouts for the 40×40 chat header icon +const COMPOSITE_LAYOUTS_SM = { + 1: [{ top: 4, left: 4, size: 32 }], + 2: [ + { top: 10, left: 1, size: 19 }, + { top: 10, right: 1, size: 19 }, + ], + 3: [ + { top: 2, left: 2, size: 17 }, + { top: 2, right: 2, size: 17 }, + { bottom: 2, left: 11, size: 17 }, + ], + 4: [ + { top: 1, left: 1, size: 18 }, + { top: 1, right: 1, size: 18 }, + { bottom: 1, left: 1, size: 18 }, + { bottom: 1, right: 1, size: 18 }, + ], +}; + +function GroupAvatarCompositeSm({ memberPreviews }) { + const members = (memberPreviews || []).slice(0, 4); + const positions = COMPOSITE_LAYOUTS_SM[members.length]; + if (!positions) return null; + 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()} +
+ ); + })} +
+ ); +} + export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onMessageDeleted, onHasTextChange, onlineUserIds = new Set() }) { const { user: currentUser } = useAuth(); const { socket } = useSocket(); @@ -267,6 +316,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
{group.is_multi_group ? 'MG' : 'UG'}
+ ) : group.composite_members?.length > 0 ? ( + ) : (
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()} diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index 7316433..5c5b29a 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -58,7 +58,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, {/* Close X */}
-
Menu
+
User Menu
diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 8ece9fa..9db8d25 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -773,6 +773,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool useEffect(()=>{setMyResp(event.my_response);setAvail(event.availability||[]);},[event]); const counts={going:0,maybe:0,not_going:0}; avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;}); + const isPast = !event.all_day && event.end_at && new Date(event.end_at) < new Date(); const handleResp=async resp=>{ const prev=myResp; @@ -801,7 +802,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
- {(isToolManager||(userId&&event.created_by===userId))&&} + {(isToolManager||(userId&&event.created_by===userId))&&!isPast&&}
@@ -823,13 +824,17 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool {!!event.track_availability&&(
Your Availability
-
- {Object.entries(RESP_LABEL).map(([key,label])=>( - - ))} -
+ {isPast ? ( +

Past event — availability is read-only.

+ ) : ( +
+ {Object.entries(RESP_LABEL).map(([key,label])=>( + + ))} +
+ )} {isToolManager&&( <>
Responses
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index a9c45dd..a75098e 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -185,9 +185,7 @@ function RegistrationTab({ onFeaturesChanged }) { )}

- Registration codes unlock application features. Contact your RosterChirp provider for a code.
- RosterChirp-Brand — unlocks Branding.  - RosterChirp-Team — unlocks Branding, Group Manager and Schedule Manager. + Registration codes unlock application features. Contact your RosterChirp provider for a code.

); diff --git a/frontend/src/components/Sidebar.css b/frontend/src/components/Sidebar.css index 0f9afe8..4c38a5a 100644 --- a/frontend/src/components/Sidebar.css +++ b/frontend/src/components/Sidebar.css @@ -181,7 +181,7 @@ .footer-menu { position: absolute; - bottom: 68px; + bottom: calc(68px + env(safe-area-inset-bottom, 0px)); left: 8px; right: 8px; background: white; diff --git a/frontend/src/components/UserFooter.jsx b/frontend/src/components/UserFooter.jsx index a5e34de..088febc 100644 --- a/frontend/src/components/UserFooter.jsx +++ b/frontend/src/components/UserFooter.jsx @@ -55,6 +55,10 @@ function DebugRow({ label, value, ok, bad }) { } // ── Test Notifications Modal ────────────────────────────────────────────────── +const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent); +const isAndroid = /Android/i.test(navigator.userAgent); +const isMobileDevice = isIOS || isAndroid; + function TestNotificationsModal({ onClose }) { const toast = useToast(); const [debugData, setDebugData] = useState(null); @@ -130,14 +134,20 @@ function TestNotificationsModal({ onClose }) {
This Device
- - {debugData && } - {debugData && } + {!isIOS && !isAndroid && } + {isAndroid && ( +
+ FCM token +
{cachedToken || 'None'}
+
+ )} + {!isIOS && debugData && } + {!isIOS && debugData && } {lastError && }
- + {!isIOS && }
@@ -158,30 +168,34 @@ function TestNotificationsModal({ onClose }) { - {/* Registered devices */} -
-
Registered Devices
- -
+ {/* Registered devices — desktop only */} + {!isMobileDevice && ( + <> +
+
Registered Devices
+ +
- {loading ? ( -

Loading…

- ) : !debugData?.subscriptions?.length ? ( -

No FCM tokens registered.

- ) : ( -
- {debugData.subscriptions.map(sub => ( -
-
- {sub.name || sub.email} - {sub.device} -
- - {sub.fcm_token} - + {loading ? ( +

Loading…

+ ) : !debugData?.subscriptions?.length ? ( +

No FCM tokens registered.

+ ) : ( +
+ {debugData.subscriptions.map(sub => ( +
+
+ {sub.name || sub.email} + {sub.device} +
+ + {sub.fcm_token} + +
+ ))}
- ))} -
+ )} + )}
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index f56331f..9a184da 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -382,6 +382,11 @@ export default function Chat() { privateGroups: prev.privateGroups.map(update), }; }); + // When composite_members is updated, do a full reload so all members + // get the enriched group data (including composite) immediately. + if (group.composite_members != null) { + loadGroups(); + } }; // Session displaced: another login on the same device type kicked us out diff --git a/frontend/src/pages/GroupManagerPage.jsx b/frontend/src/pages/GroupManagerPage.jsx index 0941c0a..5e14088 100644 --- a/frontend/src/pages/GroupManagerPage.jsx +++ b/frontend/src/pages/GroupManagerPage.jsx @@ -583,8 +583,6 @@ function U2URestrictionsTab({ allUserGroups, isMobile = false, onIF, onIB }) {

{selectedGroup.name}

Members of {selectedGroup.name} can initiate 1-to-1 direct messages with members of all checked groups. - Unchecking a group blocks initiation — existing conversations are preserved. - Admins are always exempt. If a user is in multiple groups, the least restrictive rule applies.