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
;
+ 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))&&{onClose();onEdit();}}>Edit }
+ {(isToolManager||(userId&&event.created_by===userId))&&!isPast&&{onClose();onEdit();}}>Edit }
@@ -823,13 +824,17 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
{!!event.track_availability&&(
Your Availability
-
- {Object.entries(RESP_LABEL).map(([key,label])=>(
- handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:myResp===key?RESP_COLOR[key]:'transparent',color:myResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
- {myResp===key?'✓ ':''}{label}
-
- ))}
-
+ {isPast ? (
+
Past event — availability is read-only.
+ ) : (
+
+ {Object.entries(RESP_LABEL).map(([key,label])=>(
+ handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:myResp===key?RESP_COLOR[key]:'transparent',color:myResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
+ {myResp===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 &&
}
Re-register
- Clear cached token
+ {!isIOS && Clear token }
@@ -158,30 +168,34 @@ function TestNotificationsModal({ onClose }) {
- {/* Registered devices */}
-
-
Registered Devices
-
{loading ? 'Loading…' : 'Refresh'}
-
+ {/* Registered devices — desktop only */}
+ {!isMobileDevice && (
+ <>
+
+
Registered Devices
+
{loading ? 'Loading…' : 'Refresh'}
+
- {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.