v0.6.5 various bug fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -27,36 +27,53 @@ self.addEventListener('fetch', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Track badge count in SW
|
||||
// Track badge count in SW scope
|
||||
let badgeCount = 0;
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
const data = event.data.json();
|
||||
|
||||
let data = {};
|
||||
try { data = event.data.json(); } catch (e) { return; }
|
||||
|
||||
badgeCount++;
|
||||
|
||||
// Update app badge (supported on Android Chrome and some desktop)
|
||||
if (navigator.setAppBadge) {
|
||||
navigator.setAppBadge(badgeCount).catch(() => {});
|
||||
// Update app badge
|
||||
if (self.navigator && self.navigator.setAppBadge) {
|
||||
self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'New Message', {
|
||||
// Check if app is currently visible — if so, skip the notification
|
||||
const showNotification = clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
}).then((clientList) => {
|
||||
const appVisible = clientList.some(
|
||||
(c) => c.visibilityState === 'visible'
|
||||
);
|
||||
// Still show if app is open but hidden (minimized), skip only if truly visible
|
||||
if (appVisible) return;
|
||||
|
||||
return self.registration.showNotification(data.title || 'New Message', {
|
||||
body: data.body || '',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192-maskable.png',
|
||||
data: { url: data.url || '/' },
|
||||
tag: 'jama-message', // replaces previous notification instead of stacking
|
||||
renotify: true, // still vibrate/sound even if replacing
|
||||
})
|
||||
);
|
||||
// Use unique tag per group so notifications group by conversation
|
||||
tag: data.groupId ? `jama-group-${data.groupId}` : 'jama-message',
|
||||
renotify: true,
|
||||
});
|
||||
});
|
||||
|
||||
event.waitUntil(showNotification);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
badgeCount = 0;
|
||||
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
||||
if (self.navigator && self.navigator.clearAppBadge) {
|
||||
self.navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
const url = event.notification.data?.url || '/';
|
||||
@@ -71,10 +88,22 @@ self.addEventListener('notificationclick', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Clear badge when user opens the app
|
||||
// Clear badge when app signals it
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'CLEAR_BADGE') {
|
||||
badgeCount = 0;
|
||||
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
||||
if (self.navigator && self.navigator.clearAppBadge) {
|
||||
self.navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (event.data?.type === 'SET_BADGE') {
|
||||
badgeCount = event.data.count || 0;
|
||||
if (self.navigator && self.navigator.setAppBadge) {
|
||||
if (badgeCount > 0) {
|
||||
self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||
} else {
|
||||
self.navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -91,11 +91,18 @@ function UserRow({ u, onUpdated }) {
|
||||
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
|
||||
{!!u.is_default_admin && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Default Admin</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.email}</span>
|
||||
<span style={{ color: 'var(--text-tertiary)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
Last login: {u.last_login ? new Date(u.last_login + 'Z').toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : 'Never'}
|
||||
</span>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.email}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 1 }}>
|
||||
Last online: {(() => {
|
||||
if (!u.last_online) return 'Never';
|
||||
const d = new Date(u.last_online + 'Z');
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
|
||||
d.setHours(0,0,0,0);
|
||||
if (d >= today) return 'Today';
|
||||
if (d >= yesterday) return 'Yesterday';
|
||||
return d.toISOString().slice(0,10);
|
||||
})()}
|
||||
</div>
|
||||
{!!u.must_change_password && <div className="text-xs" style={{ color: 'var(--warning)' }}>⚠ Must change password</div>}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,16 @@ export function SocketProvider({ children }) {
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||
const socket = io('/', { auth: { token }, transports: ['websocket'] });
|
||||
const socket = io('/', {
|
||||
auth: { token },
|
||||
transports: ['websocket'],
|
||||
// Aggressive reconnection so mobile resume is fast
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 500,
|
||||
reconnectionDelayMax: 3000,
|
||||
timeout: 8000,
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
@@ -33,7 +42,21 @@ export function SocketProvider({ children }) {
|
||||
socket.on('user:online', ({ userId }) => setOnlineUsers(prev => new Set([...prev, userId])));
|
||||
socket.on('user:offline', ({ userId }) => setOnlineUsers(prev => { const s = new Set(prev); s.delete(userId); return s; }));
|
||||
|
||||
return () => { socket.disconnect(); socketRef.current = null; };
|
||||
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (socketRef.current && !socketRef.current.connected) {
|
||||
socketRef.current.connect();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,39 +63,54 @@ export default function Chat() {
|
||||
|
||||
useEffect(() => { loadGroups(); }, [loadGroups]);
|
||||
|
||||
// Register push subscription
|
||||
// Register / refresh push subscription
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
(async () => {
|
||||
|
||||
const registerPush = async () => {
|
||||
try {
|
||||
const permission = Notification.permission;
|
||||
if (permission === 'denied') return;
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
if (existing) {
|
||||
// Re-register to keep subscription fresh
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
|
||||
body: JSON.stringify(existing.toJSON())
|
||||
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
|
||||
if (!sub) {
|
||||
// First time or subscription was lost — request permission then subscribe
|
||||
const granted = permission === 'granted'
|
||||
? 'granted'
|
||||
: await Notification.requestPermission();
|
||||
if (granted !== 'granted') return;
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') return;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||
});
|
||||
|
||||
// Always re-register subscription with the server (keeps it fresh on mobile)
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
|
||||
body: JSON.stringify(sub.toJSON())
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
});
|
||||
console.log('[Push] Subscribed');
|
||||
console.log('[Push] Subscription registered');
|
||||
} catch (e) {
|
||||
console.warn('[Push] Subscription failed:', e.message);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
registerPush();
|
||||
|
||||
// Bug A fix: re-register push subscription when app returns to foreground
|
||||
// Mobile browsers can drop push subscriptions when the app is backgrounded
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'visible') registerPush();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||
}, []);
|
||||
|
||||
// Socket message events to update group previews
|
||||
@@ -113,10 +128,13 @@ export default function Chat() {
|
||||
privateGroups: prev.privateGroups.map(updateGroup),
|
||||
};
|
||||
});
|
||||
// Don't badge: message is from this user, or group is currently open
|
||||
// Don't badge own messages
|
||||
if (msg.user_id === user?.id) return;
|
||||
// Bug C fix: count unread even in the active group when window is hidden/minimized
|
||||
const groupIsActive = msg.group_id === activeGroupId;
|
||||
const windowHidden = document.visibilityState === 'hidden';
|
||||
setUnreadGroups(prev => {
|
||||
if (msg.group_id === activeGroupId) return prev;
|
||||
if (groupIsActive && !windowHidden) return prev; // visible & active: no badge
|
||||
const next = new Map(prev);
|
||||
next.set(msg.group_id, (next.get(msg.group_id) || 0) + 1);
|
||||
return next;
|
||||
@@ -173,12 +191,26 @@ export default function Chat() {
|
||||
socket.on('group:deleted', handleGroupDeleted);
|
||||
socket.on('group:updated', handleGroupUpdated);
|
||||
|
||||
// Bug B fix: on reconnect, reload groups to catch any messages missed while offline
|
||||
const handleReconnect = () => { loadGroups(); };
|
||||
socket.on('connect', handleReconnect);
|
||||
|
||||
// Bug B fix: also reload on visibility restore if socket is already connected
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'visible' && socket.connected) {
|
||||
loadGroups();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => {
|
||||
socket.off('message:new', handleNewMsg);
|
||||
socket.off('notification:new', handleNotification);
|
||||
socket.off('group:new', handleGroupNew);
|
||||
socket.off('group:deleted', handleGroupDeleted);
|
||||
socket.off('group:updated', handleGroupUpdated);
|
||||
socket.off('connect', handleReconnect);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user