v0.12.15 PWA bug and schema error for GM

This commit is contained in:
2026-03-24 08:58:09 -04:00
parent 117b5cbe4c
commit 7c0c3e1132
6 changed files with 94 additions and 68 deletions

View File

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

View File

@@ -156,8 +156,8 @@ router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (re
`, [mg.id, uid]); `, [mg.id, uid]);
if (!stillIn) { if (!stillIn) {
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]); await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',mg.dm_group_id)); io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',mg.dm_group_id));
io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id }); io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
} }
} }
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A group has been removed from this conversation.`); await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A group has been removed from this conversation.`);
@@ -175,7 +175,7 @@ router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (r
if (mg.dm_group_id) { if (mg.dm_group_id) {
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [mg.dm_group_id])).map(r => r.user_id); const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [mg.dm_group_id])).map(r => r.user_id);
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [mg.dm_group_id]); await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [mg.dm_group_id]);
for (const uid of members) io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id }); for (const uid of members) io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
} }
await exec(req.schema, 'DELETE FROM multi_group_dms WHERE id=$1', [mg.id]); await exec(req.schema, 'DELETE FROM multi_group_dms WHERE id=$1', [mg.id]);
res.json({ success: true }); res.json({ success: true });
@@ -268,8 +268,8 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
if (!newIds.has(uid)) { if (!newIds.has(uid)) {
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, uid]); await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, uid]);
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, uid]); await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, uid]);
io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',ug.dm_group_id)); io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',ug.dm_group_id));
io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id }); io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id });
removedUids.push(uid); removedUids.push(uid);
} }
} }
@@ -303,8 +303,8 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
`, [mg.id, uid]); `, [mg.id, uid]);
if (!stillIn) { if (!stillIn) {
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]); await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',mg.dm_group_id)); io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',mg.dm_group_id));
io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id }); io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
} }
} }
if (addedUids.length === 1) { if (addedUids.length === 1) {
@@ -335,7 +335,7 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
if (ug.dm_group_id) { if (ug.dm_group_id) {
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [ug.dm_group_id])).map(r => r.user_id); const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [ug.dm_group_id])).map(r => r.user_id);
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [ug.dm_group_id]); await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [ug.dm_group_id]);
for (const uid of members) { io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',ug.dm_group_id)); io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id }); } for (const uid of members) { io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',ug.dm_group_id)); io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id }); }
} }
await exec(req.schema, 'DELETE FROM user_groups WHERE id=$1', [ug.id]); await exec(req.schema, 'DELETE FROM user_groups WHERE id=$1', [ug.id]);
res.json({ success: true }); res.json({ success: true });

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.12.14}" VERSION="${1:-0.12.15}"
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.14", "version": "0.12.15",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -19,6 +19,9 @@ export default function ProfileModal({ onClose }) {
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications'
const [pushTesting, setPushTesting] = useState(false); const [pushTesting, setPushTesting] = useState(false);
const [pushResult, setPushResult] = useState(null); const [pushResult, setPushResult] = useState(null);
const [notifPermission, setNotifPermission] = useState(
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
);
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag); const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0); const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
@@ -172,58 +175,77 @@ export default function ProfileModal({ onClose }) {
{tab === 'notifications' && ( {tab === 'notifications' && (
<div className="flex-col gap-3"> <div className="flex-col gap-3">
<div style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.5 }}> {notifPermission !== 'granted' && notifPermission !== 'unsupported' && (
<p style={{ margin: '0 0 8px' }}>Tap <strong>Send Test Notification</strong> to trigger a push to this device. The notification will arrive shortly if everything is configured correctly.</p> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-variant)' }}>
<p style={{ margin: 0 }}>If it doesn't arrive, check:<br/> <div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
• Notification permission granted (browser prompt)<br/> {notifPermission === 'denied'
Android Settings → Apps → RosterChirp → Notifications → Enabled<br/> ? 'Notifications are blocked. Enable them in Android Settings → Apps → RosterChirp → Notifications.'
• App is backgrounded when the test fires : 'Push notifications are not yet enabled on this device.'}
</p> </div>
</div> {notifPermission === 'default' && (
<div className="flex gap-2"> <button className="btn btn-primary btn-sm" onClick={async () => {
<button const result = await Notification.requestPermission();
className="btn btn-primary" setNotifPermission(result);
style={{ flex: 1 }} if (result === 'granted') window.dispatchEvent(new CustomEvent('rosterchirp:push-init'));
disabled={pushTesting} }}>Enable Notifications</button>
onClick={async () => { )}
setPushTesting(true); </div>
setPushResult(null); )}
try { {notifPermission === 'granted' && (
const { results } = await api.testPush('data'); <div style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
setPushResult({ ok: true, results, mode: 'data' }); <p style={{ margin: '0 0 8px' }}>Tap <strong>Send Test Notification</strong> to trigger a push to this device. The notification will arrive shortly if everything is configured correctly.</p>
} catch (e) { <p style={{ margin: 0 }}>If it doesn't arrive, check:<br/>
setPushResult({ ok: false, error: e.message }); • Android Settings → Apps → RosterChirp → Notifications → Enabled<br/>
} finally { • App is backgrounded when the test fires
setPushTesting(false); </p>
} </div>
}} )}
> {notifPermission === 'granted' && (<>
{pushTesting ? 'Sending' : 'Test (via SW)'} <div className="flex gap-2">
</button> <button
<button className="btn btn-primary"
className="btn btn-secondary" style={{ flex: 1 }}
style={{ flex: 1 }} disabled={pushTesting}
disabled={pushTesting} onClick={async () => {
onClick={async () => { setPushTesting(true);
setPushTesting(true); setPushResult(null);
setPushResult(null); try {
try { const { results } = await api.testPush('data');
const { results } = await api.testPush('browser'); setPushResult({ ok: true, results, mode: 'data' });
setPushResult({ ok: true, results, mode: 'browser' }); } catch (e) {
} catch (e) { setPushResult({ ok: false, error: e.message });
setPushResult({ ok: false, error: e.message }); } finally {
} finally { setPushTesting(false);
setPushTesting(false); }
} }}
}} >
> {pushTesting ? 'Sending' : 'Test (via SW)'}
{pushTesting ? 'Sending' : 'Test (via Browser)'} </button>
</button> <button
</div> className="btn btn-secondary"
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.4 }}> style={{ flex: 1 }}
<strong>Test (via SW)</strong> — normal production path, service worker shows notification.<br/> disabled={pushTesting}
<strong>Test (via Browser)</strong> — bypasses service worker; Chrome displays directly. onClick={async () => {
</div> setPushTesting(true);
setPushResult(null);
try {
const { results } = await api.testPush('browser');
setPushResult({ ok: true, results, mode: 'browser' });
} catch (e) {
setPushResult({ ok: false, error: e.message });
} finally {
setPushTesting(false);
}
}}
>
{pushTesting ? 'Sending' : 'Test (via Browser)'}
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.4 }}>
<strong>Test (via SW)</strong> — normal production path, service worker shows notification.<br/>
<strong>Test (via Browser)</strong> — bypasses service worker; Chrome displays directly.
</div>
</>)}
{pushResult && ( {pushResult && (
<div style={{ <div style={{
padding: '10px 12px', padding: '10px 12px',

View File

@@ -121,10 +121,9 @@ export default function Chat() {
const reg = await navigator.serviceWorker.ready; const reg = await navigator.serviceWorker.ready;
if (Notification.permission !== 'granted') { // Never auto-request permission — that triggers a dialog on PWA launch.
const granted = await Notification.requestPermission(); // Permission is requested explicitly from the Notifications tab in the profile modal.
if (granted !== 'granted') return; if (Notification.permission !== 'granted') return;
}
// Do NOT call deleteToken() here. Deleting the token on every page load (or // Do NOT call deleteToken() here. Deleting the token on every page load (or
// every visibility-change) forces Chrome to create a new Web Push subscription // every visibility-change) forces Chrome to create a new Web Push subscription
@@ -174,8 +173,13 @@ export default function Chat() {
const handleVisibility = () => { const handleVisibility = () => {
if (document.visibilityState === 'visible') registerPush(); if (document.visibilityState === 'visible') registerPush();
}; };
const handlePushInit = () => registerPush();
document.addEventListener('visibilitychange', handleVisibility); document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility); window.addEventListener('rosterchirp:push-init', handlePushInit);
return () => {
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('rosterchirp:push-init', handlePushInit);
};
}, []); }, []);
// When a message is deleted, update the sidebar preview immediately. // When a message is deleted, update the sidebar preview immediately.