v0.12.9 bug fixes (FCM and list ordering)

This commit is contained in:
2026-03-23 21:43:35 -04:00
parent 01f37e60be
commit 477b25dfa0
9 changed files with 32 additions and 95 deletions

View File

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

View File

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

View File

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

View File

@@ -194,7 +194,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
Members ({members.length})
</div>
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
{members.map(m => (
{[...members].sort((a, b) => a.name.localeCompare(b.name)).map(m => (
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
<Avatar user={m} size="sm" />
<span className="flex-1 text-sm">{m.name}</span>

View File

@@ -141,7 +141,7 @@ export default function NewChatModal({ onClose, onCreated }) {
)}
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
{users.filter(u => u.id !== user.id && u.allow_dm !== 0).map(u => (
{users.filter(u => u.id !== user.id && u.allow_dm !== 0).sort((a, b) => a.name.localeCompare(b.name)).map(u => (
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
<Avatar user={u} size="sm" />

View File

@@ -18,7 +18,7 @@ function TeamManagementTab() {
const [saving, setSaving] = useState(false);
useEffect(() => {
api.getUserGroups().then(({ groups }) => setUserGroups(groups || [])).catch(() => {});
api.getUserGroups().then(({ groups }) => setUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {});
api.getSettings().then(({ settings }) => {
// Read from unified key, fall back to legacy key
setToolManagers(JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'));
@@ -313,80 +313,6 @@ function PushDebugTab() {
);
}
// ── Web Push Tab ──────────────────────────────────────────────────────────────
function WebPushTab() {
const toast = useToast();
const [vapidPublic, setVapidPublic] = useState('');
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [showRegenWarning, setShowRegenWarning] = useState(false);
useEffect(() => {
api.getSettings().then(({ settings }) => {
setVapidPublic(settings.vapid_public || '');
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const doGenerate = async () => {
setGenerating(true);
setShowRegenWarning(false);
try {
const { publicKey } = await api.generateVapidKeys();
setVapidPublic(publicKey);
toast('VAPID keys generated. Push notifications are now active.', 'success');
} catch (e) {
toast(e.message || 'Failed to generate keys', 'error');
} finally { setGenerating(false); }
};
if (loading) return <p style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Loading</p>;
return (
<div>
<div className="settings-section-label" style={{ marginBottom: 12 }}>Web Push Notifications (VAPID)</div>
{vapidPublic ? (
<div style={{ marginBottom: 16 }}>
<div style={{ background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 12px', marginBottom: 10 }}>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}>Public Key</div>
<code style={{ fontSize: 11, color: 'var(--text-primary)', wordBreak: 'break-all', lineHeight: 1.5, display: 'block' }}>{vapidPublic}</code>
</div>
<span style={{ fontSize: 13, color: 'var(--success)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Push notifications active
</span>
</div>
) : (
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
No VAPID keys found. Generate keys to enable Web Push notifications.
</p>
)}
{showRegenWarning && (
<div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '14px 16px', marginBottom: 16 }}>
<p style={{ fontSize: 13, fontWeight: 600, color: 'var(--error)', marginBottom: 8 }}> Regenerate VAPID keys?</p>
<p style={{ fontSize: 13, color: '#5c2c28', marginBottom: 12, lineHeight: 1.5 }}>
Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Users will need to re-enable notifications.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={doGenerate} disabled={generating}>{generating ? 'Generating…' : 'Yes, regenerate keys'}</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>Cancel</button>
</div>
</div>
)}
{!showRegenWarning && (
<button className="btn btn-primary btn-sm" onClick={() => vapidPublic ? setShowRegenWarning(true) : doGenerate()} disabled={generating}>
{generating ? 'Generating…' : vapidPublic ? 'Regenerate Keys' : 'Generate Keys'}
</button>
)}
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 12, lineHeight: 1.5 }}>
Requires HTTPS. On iOS, the app must be installed to the home screen first.
</p>
</div>
);
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function SettingsModal({ onClose, onFeaturesChanged }) {
const [tab, setTab] = useState('registration');
@@ -406,7 +332,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
const tabs = [
isTeam && { id: 'team', label: 'Team Management' },
{ id: 'registration', label: 'Registration' },
{ id: 'webpush', label: 'Web Push' },
{ id: 'pushdebug', label: 'Push Debug' },
].filter(Boolean);
@@ -431,7 +356,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
{tab === 'team' && <TeamManagementTab />}
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
{tab === 'webpush' && <WebPushTab />}
{tab === 'pushdebug' && <PushDebugTab />}
</div>
</div>

View File

@@ -66,10 +66,21 @@ if ('serviceWorker' in navigator) {
currentScale = Math.round(newScale * 100) / 100;
document.documentElement.style.setProperty('--font-scale', currentScale);
} else if (e.touches.length === 1 && isStandalone) {
// Single finger: block pull-to-refresh at top of page
// Single finger: block pull-to-refresh only when no scrollable ancestor
// has scrolled content above the viewport.
// Without this ancestor check, document.scrollTop is always 0 in this
// flex layout, so the naive condition blocked ALL upward swipes (dy > 0),
// making any scroll container impossible to scroll back up after reaching
// the bottom — freezing the window.
const dy = e.touches[0].clientY - singleStartY;
if (dy > 0 && document.documentElement.scrollTop === 0 && document.body.scrollTop === 0) {
e.preventDefault();
if (dy > 0) {
let el = e.target;
let canScrollUp = false;
while (el && el !== document.documentElement) {
if (el.scrollTop > 0) { canScrollUp = true; break; }
el = el.parentElement;
}
if (!canScrollUp) e.preventDefault();
}
}
}, { passive: false });

View File

@@ -71,7 +71,7 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
const [showDelete, setShowDelete] = useState(false);
const load = useCallback(() =>
api.getUserGroups().then(({ groups }) => setGroups(groups)).catch(() => {}), []);
api.getUserGroups().then(({ groups }) => setGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}), []);
useEffect(() => { load(); }, [load]);
const selectGroup = async (g) => {
@@ -220,7 +220,7 @@ function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey, isMobile = fa
const [showDelete, setShowDelete] = useState(false);
const load = useCallback(() =>
api.getMultiGroupDms().then(({ dms }) => setDms(dms||[])).catch(() => {}), []);
api.getMultiGroupDms().then(({ dms }) => setDms([...(dms||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}), []);
useEffect(() => { load(); }, [load, refreshKey]);
const clearSelection = () => { setSelected(null); setDmName(''); setGroupIds(new Set()); setSavedGroupIds(new Set()); setShowDelete(false); };
@@ -556,8 +556,8 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
const onRefresh = () => setRefreshKey(k => k+1);
useEffect(() => {
api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active'))).catch(() => {});
api.getUserGroups().then(({ groups }) => setAllUserGroups(groups)).catch(() => {});
api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active').sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {});
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {});
}, [refreshKey]);
// Nav item helper — matches Schedule page style

View File

@@ -300,11 +300,13 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {});
}, [load]);
const filtered = users.filter(u =>
!search || u.name?.toLowerCase().includes(search.toLowerCase()) ||
u.display_name?.toLowerCase().includes(search.toLowerCase()) ||
u.email?.toLowerCase().includes(search.toLowerCase())
);
const filtered = users
.filter(u =>
!search || u.name?.toLowerCase().includes(search.toLowerCase()) ||
u.display_name?.toLowerCase().includes(search.toLowerCase()) ||
u.email?.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => a.name.localeCompare(b.name));
// ── Nav item helper (matches Schedule page style) ─────────────────────────
const navItem = (label, key) => (
@@ -354,7 +356,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
)}
{/* Content */}
<div style={{ flex:1, overflowY:'auto', padding:16 }}>
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 72 : 16, overscrollBehavior: 'contain' }}>
{tab === 'users' && (
<>
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}