v0.12.33 text cleanup of the app and bug fixes.

This commit is contained in:
2026-03-28 12:55:53 -04:00
parent eb3e45d88f
commit fb9d4dc956
12 changed files with 124 additions and 49 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-backend", "name": "rosterchirp-backend",
"version": "0.12.32", "version": "0.12.33",
"description": "RosterChirp backend server", "description": "RosterChirp backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -281,12 +281,16 @@ 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 // For non-managed private groups, always notify existing members of the updated group,
if (!group.is_managed && !group.is_direct && parseInt(preAddCount.cnt) <= 3) { // and regenerate composite when pre-add count was ≤3 and new total reaches ≥3.
const newTotal = parseInt(preAddCount.cnt) + 1; if (!group.is_managed && !group.is_direct) {
const preCount = parseInt(preAddCount.cnt);
if (preCount <= 3) {
const newTotal = preCount + 1;
if (newTotal >= 3) { if (newTotal >= 3) {
await computeAndStoreComposite(req.schema, group.id); await computeAndStoreComposite(req.schema, group.id);
} }
}
await emitGroupUpdated(req.schema, io, 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));

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.12.32}" VERSION="${1:-0.12.33}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp" IMAGE_NAME="rosterchirp"

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-frontend", "name": "rosterchirp-frontend",
"version": "0.12.32", "version": "0.12.33",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -14,6 +14,55 @@ function nameToColor(name) {
return AVATAR_COLORS[(name || '').charCodeAt(0) % AVATAR_COLORS.length]; 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 (
<div className="group-icon-sm" style={{ background: '#1a1a2e', position: 'relative', padding: 0, overflow: 'hidden' }}>
{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 <img key={m.id} src={m.avatar} alt={m.name} style={{ ...base, objectFit: 'cover' }} />;
return (
<div key={m.id} style={{ ...base, background: nameToColor(m.name), display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: Math.round(pos.size * 0.42), fontWeight: 700, color: 'white' }}>
{(m.name || '')[0]?.toUpperCase()}
</div>
);
})}
</div>
);
}
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onMessageDeleted, onHasTextChange, onlineUserIds = new Set() }) { export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onMessageDeleted, onHasTextChange, onlineUserIds = new Set() }) {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const { socket } = useSocket(); const { socket } = useSocket();
@@ -267,6 +316,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
<div className="group-icon-sm" style={{ background: avatarColors.dm, borderRadius: 8, flexShrink: 0, fontSize: 11, fontWeight: 700 }}> <div className="group-icon-sm" style={{ background: avatarColors.dm, borderRadius: 8, flexShrink: 0, fontSize: 11, fontWeight: 700 }}>
{group.is_multi_group ? 'MG' : 'UG'} {group.is_multi_group ? 'MG' : 'UG'}
</div> </div>
) : group.composite_members?.length > 0 ? (
<GroupAvatarCompositeSm memberPreviews={group.composite_members} />
) : ( ) : (
<div className="group-icon-sm" style={{ background: group.type === 'public' ? avatarColors.public : avatarColors.dm, flexShrink: 0 }}> <div className="group-icon-sm" style={{ background: group.type === 'public' ? avatarColors.public : avatarColors.dm, flexShrink: 0 }}>
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()} {group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}

View File

@@ -58,7 +58,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
{/* Close X */} {/* Close X */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<div className="nav-drawer-section-label" style={{ margin: 0, padding: 0 }}>Menu</div> <div className="nav-drawer-section-label" style={{ margin: 0, padding: 0 }}>User Menu</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }} aria-label="Close menu"> <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }} aria-label="Close menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>

View File

@@ -773,6 +773,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
useEffect(()=>{setMyResp(event.my_response);setAvail(event.availability||[]);},[event]); useEffect(()=>{setMyResp(event.my_response);setAvail(event.availability||[]);},[event]);
const counts={going:0,maybe:0,not_going:0}; const counts={going:0,maybe:0,not_going:0};
avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;}); 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 handleResp=async resp=>{
const prev=myResp; const prev=myResp;
@@ -801,7 +802,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</div> </div>
</div> </div>
<div style={{display:'flex',gap:6,flexShrink:0}}> <div style={{display:'flex',gap:6,flexShrink:0}}>
{(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>} {(isToolManager||(userId&&event.created_by===userId))&&!isPast&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>}
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> <button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div> </div>
</div> </div>
@@ -823,6 +824,9 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
{!!event.track_availability&&( {!!event.track_availability&&(
<div style={{borderTop:'1px solid var(--border)',paddingTop:16,marginTop:4}}> <div style={{borderTop:'1px solid var(--border)',paddingTop:16,marginTop:4}}>
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:10}}>Your Availability</div> <div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:10}}>Your Availability</div>
{isPast ? (
<p style={{fontSize:13,color:'var(--text-tertiary)',marginBottom:16}}>Past event availability is read-only.</p>
) : (
<div style={{display:'flex',gap:8,marginBottom:16}}> <div style={{display:'flex',gap:8,marginBottom:16}}>
{Object.entries(RESP_LABEL).map(([key,label])=>( {Object.entries(RESP_LABEL).map(([key,label])=>(
<button key={key} onClick={()=>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'}}> <button key={key} onClick={()=>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'}}>
@@ -830,6 +834,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</button> </button>
))} ))}
</div> </div>
)}
{isToolManager&&( {isToolManager&&(
<> <>
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:8}}>Responses</div> <div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:8}}>Responses</div>

View File

@@ -185,9 +185,7 @@ function RegistrationTab({ onFeaturesChanged }) {
)} )}
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}> <p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}>
Registration codes unlock application features. Contact your RosterChirp provider for a code.<br /> Registration codes unlock application features. Contact your RosterChirp provider for a code.
<strong>RosterChirp-Brand</strong> unlocks Branding.&nbsp;
<strong>RosterChirp-Team</strong> unlocks Branding, Group Manager and Schedule Manager.
</p> </p>
</div> </div>
); );

View File

@@ -181,7 +181,7 @@
.footer-menu { .footer-menu {
position: absolute; position: absolute;
bottom: 68px; bottom: calc(68px + env(safe-area-inset-bottom, 0px));
left: 8px; left: 8px;
right: 8px; right: 8px;
background: white; background: white;

View File

@@ -55,6 +55,10 @@ function DebugRow({ label, value, ok, bad }) {
} }
// ── Test Notifications Modal ────────────────────────────────────────────────── // ── 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 }) { function TestNotificationsModal({ onClose }) {
const toast = useToast(); const toast = useToast();
const [debugData, setDebugData] = useState(null); const [debugData, setDebugData] = useState(null);
@@ -130,14 +134,20 @@ function TestNotificationsModal({ onClose }) {
<div style={sectionLabel}>This Device</div> <div style={sectionLabel}>This Device</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
<DebugRow label="Permission" value={permission} ok={permission === 'granted'} bad={permission === 'denied'} /> <DebugRow label="Permission" value={permission} ok={permission === 'granted'} bad={permission === 'denied'} />
<DebugRow label="Cached FCM token" value={cachedToken ? cachedToken.slice(0, 36) + '…' : 'None'} ok={!!cachedToken} bad={!cachedToken} /> {!isIOS && !isAndroid && <DebugRow label="FCM token" value={cachedToken ? cachedToken.slice(0, 36) + '…' : 'None'} ok={!!cachedToken} bad={!cachedToken} />}
{debugData && <DebugRow label="FCM env vars" value={debugData.fcmConfigured ? 'Present' : 'Missing'} ok={debugData.fcmConfigured} bad={!debugData.fcmConfigured} />} {isAndroid && (
{debugData && <DebugRow label="Firebase Admin" value={debugData.firebaseAdminReady ? 'Ready' : 'Not ready'} ok={debugData.firebaseAdminReady} bad={!debugData.firebaseAdminReady} />} <div style={{ fontSize: 13 }}>
<span style={{ color: 'var(--text-secondary)' }}>FCM token</span>
<div style={{ color: cachedToken ? 'var(--success)' : 'var(--error)', fontFamily: 'monospace', fontSize: 11, marginTop: 3, wordBreak: 'break-all', lineHeight: 1.5 }}>{cachedToken || 'None'}</div>
</div>
)}
{!isIOS && debugData && <DebugRow label="FCM env vars" value={debugData.fcmConfigured ? 'Present' : 'Missing'} ok={debugData.fcmConfigured} bad={!debugData.fcmConfigured} />}
{!isIOS && debugData && <DebugRow label="Firebase Admin" value={debugData.firebaseAdminReady ? 'Ready' : 'Not ready'} ok={debugData.firebaseAdminReady} bad={!debugData.firebaseAdminReady} />}
{lastError && <DebugRow label="Last reg. error" value={lastError} bad={true} />} {lastError && <DebugRow label="Last reg. error" value={lastError} bad={true} />}
</div> </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-sm btn-primary" onClick={reregister}>Re-register</button> <button className="btn btn-sm btn-primary" onClick={reregister}>Re-register</button>
<button className="btn btn-sm btn-secondary" onClick={clearToken}>Clear cached token</button> {!isIOS && <button className="btn btn-sm btn-secondary" onClick={clearToken}>Clear token</button>}
</div> </div>
</div> </div>
@@ -158,7 +168,9 @@ function TestNotificationsModal({ onClose }) {
</div> </div>
</div> </div>
{/* Registered devices */} {/* Registered devices — desktop only */}
{!isMobileDevice && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div className="settings-section-label" style={{ margin: 0 }}>Registered Devices</div> <div className="settings-section-label" style={{ margin: 0 }}>Registered Devices</div>
<button className="btn btn-sm btn-secondary" onClick={load} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button> <button className="btn btn-sm btn-secondary" onClick={load} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
@@ -183,6 +195,8 @@ function TestNotificationsModal({ onClose }) {
))} ))}
</div> </div>
)} )}
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -382,6 +382,11 @@ export default function Chat() {
privateGroups: prev.privateGroups.map(update), 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 // Session displaced: another login on the same device type kicked us out

View File

@@ -583,8 +583,6 @@ function U2URestrictionsTab({ allUserGroups, isMobile = false, onIF, onIB }) {
<h3 style={{ fontSize:16, fontWeight:700, margin:'0 0 6px' }}>{selectedGroup.name}</h3> <h3 style={{ fontSize:16, fontWeight:700, margin:'0 0 6px' }}>{selectedGroup.name}</h3>
<p style={{ fontSize:13, color:'var(--text-secondary)', margin:0, lineHeight:1.5 }}> <p style={{ fontSize:13, color:'var(--text-secondary)', margin:0, lineHeight:1.5 }}>
Members of <strong>{selectedGroup.name}</strong> can initiate 1-to-1 direct messages with members of all <strong>checked</strong> groups. Members of <strong>{selectedGroup.name}</strong> can initiate 1-to-1 direct messages with members of all <strong>checked</strong> 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.
</p> </p>
</div> </div>