From daaf4a48058b658a44c450ab0210b8e0ad702167 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Tue, 10 Mar 2026 19:02:14 -0400 Subject: [PATCH] v0.6.5 various bug fixes --- .env.example | 2 +- backend/package.json | 2 +- backend/src/index.js | 5 ++ backend/src/models/db.js | 6 +- backend/src/routes/auth.js | 3 - backend/src/routes/users.js | 2 +- build.sh | 2 +- data/help.md | 38 +++++----- frontend/package.json | 2 +- frontend/public/sw.js | 59 +++++++++++---- frontend/src/components/UserManagerModal.jsx | 17 +++-- frontend/src/contexts/SocketContext.jsx | 27 ++++++- frontend/src/pages/Chat.jsx | 76 ++++++++++++++------ 13 files changed, 166 insertions(+), 75 deletions(-) diff --git a/.env.example b/.env.example index 94aff1b..dfb50c0 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ TZ=UTC # Copy this file to .env and customize # Image version to run (set by build.sh, or use 'latest') -JAMA_VERSION=0.6.5 +JAMA_VERSION=0.6.6 # Default admin credentials (used on FIRST RUN only) ADMIN_NAME=Admin User diff --git a/backend/package.json b/backend/package.json index 62bbed9..57c1069 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.6.5", + "version": "0.6.6", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/index.js b/backend/src/index.js index 6cdd09e..2b187ec 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -127,6 +127,9 @@ io.on('connection', (socket) => { if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set()); onlineUsers.get(userId).add(socket.id); + // Record last_online timestamp + getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId); + // Broadcast online status io.emit('user:online', { userId }); @@ -200,6 +203,7 @@ io.on('connection', (socket) => { title: senderName, body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100), url: '/', + groupId, badge: 1, }).catch(() => {}); } else { @@ -340,6 +344,7 @@ io.on('connection', (socket) => { onlineUsers.get(userId).delete(socket.id); if (onlineUsers.get(userId).size === 0) { onlineUsers.delete(userId); + getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId); io.emit('user:offline', { userId }); } } diff --git a/backend/src/models/db.js b/backend/src/models/db.js index 18bcf25..71b58ff 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -182,10 +182,10 @@ function initDb() { console.log('[DB] Migration: added direct_peer2_id column'); } catch (e) { /* column already exists */ } - // Migration: last_login timestamp per user + // Migration: last_online timestamp per user try { - db.exec("ALTER TABLE users ADD COLUMN last_login TEXT"); - console.log('[DB] Migration: added last_login column'); + db.exec("ALTER TABLE users ADD COLUMN last_online TEXT"); + console.log('[DB] Migration: added last_online column'); } catch (e) { /* column already exists */ } // Migration: help_dismissed preference per user diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 8901747..8a1ace5 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -24,9 +24,6 @@ router.post('/login', (req, res) => { const valid = bcrypt.compareSync(password, user.password); if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); - // Record last login timestamp - db.prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(user.id); - const token = generateToken(user.id); const ua = req.headers['user-agent'] || ''; const device = setActiveSession(user.id, token, ua); // displaces prior session on same device class diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 9fc4b64..79b67ec 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -49,7 +49,7 @@ function getDefaultPassword(db) { router.get('/', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); const users = db.prepare(` - SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, created_at, last_login + SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, created_at, last_online FROM users WHERE status != 'deleted' ORDER BY created_at ASC `).all(); diff --git a/build.sh b/build.sh index 870a09c..e7fb74e 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.6.5}" +VERSION="${1:-0.6.6}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/data/help.md b/data/help.md index 1546cc8..b07dc29 100644 --- a/data/help.md +++ b/data/help.md @@ -1,6 +1,6 @@ -# Getting Started with Jama +# Getting Started with JAMA -Welcome to **Jama** — your private, self-hosted team messaging app. +Welcome to **JAMA** — your private, self-hosted team messaging app. --- @@ -22,7 +22,7 @@ Type your message in the input box at the bottom and press **Enter** to send. - **Shift + Enter** adds a new line without sending - Tap the **+** button to attach a photo or emoji -- Use the **camera** icon to take a photo directly (mobile) +- Use the **camera** icon to take a photo directly (mobile only) ### Mentioning Someone Type **@** followed by the person's name to mention them. Select from the dropdown that appears. Mentioned users receive a notification. @@ -39,11 +39,13 @@ Hover over any message and click the **emoji** button to react with an emoji. ## Direct Messages -To start a private conversation with one person: +Two ways to start a private conversation with one person: -1. Click the **pencil / new chat** icon in the sidebar +1. Click the **New Chat** icon in the sidebar 2. Select one user from the list 3. Click **Start Conversation** +4. Click the users avatar in a message to bring up the profile +5. Click **Direct Message** --- @@ -51,12 +53,12 @@ To start a private conversation with one person: To create a group conversation: -1. Click the **pencil / new chat** icon -2. Select two or more users -3. Enter a **Group Name** +1. Click the **new chat** icon +2. Select two or more users from the +3. Enter a **Message Name** 4. Click **Create** -> If a group with the exact same members already exists, you will be redirected to it automatically. +> If a group with the exact same members already exists, you will be redirected to it automatically to help avoid duplication. --- @@ -64,7 +66,7 @@ To create a group conversation: Click your name or avatar at the bottom of the sidebar to: -- Update your **display name** (shown to others instead of your username) +- Update your **display name** (displayed in message windows) - Add an **about me** note - Upload a **profile photo** - Change your **password** @@ -75,12 +77,12 @@ Click your name or avatar at the bottom of the sidebar to: You can set a personal display name for any group that only you will see: -1. Open the group -2. Click the **ⓘ info** icon in the top bar +1. Open the message +2. Click the **message info** icon in the top right 3. Enter your custom name under **Your custom name** 4. Click **Save** -Other members still see the original group name. +Other members still see the original group name, unless they change to customised name. --- @@ -88,8 +90,8 @@ Other members still see the original group name. Admins can access **Settings** from the user menu to configure: -- App name and logo -- Default user password +- Branding a new app name and logo +- Set new user password - Notification preferences --- @@ -98,8 +100,4 @@ Admins can access **Settings** from the user menu to configure: - 🌙 Toggle **dark mode** from the user menu - 🔔 Enable **push notifications** when prompted to receive alerts when the app is closed -- 📱 Install Jama as a **PWA** on your device — tap *Add to Home Screen* in your browser menu for an app-like experience - ---- - -*This help file can be updated by your administrator at any time.* +- 📱 Install JAMA as a **PWA** on your device — tap *Add to Home Screen* in your browser menu for an app-like experience diff --git a/frontend/package.json b/frontend/package.json index fa8384d..e228d01 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.6.5", + "version": "0.6.6", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 63066d8..68ced58 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -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(() => {}); + } + } } }); diff --git a/frontend/src/components/UserManagerModal.jsx b/frontend/src/components/UserManagerModal.jsx index e6f1555..33038fc 100644 --- a/frontend/src/components/UserManagerModal.jsx +++ b/frontend/src/components/UserManagerModal.jsx @@ -91,11 +91,18 @@ function UserRow({ u, onUpdated }) { {u.status !== 'active' && {u.status}} {!!u.is_default_admin && Default Admin} -
- {u.email} - - Last login: {u.last_login ? new Date(u.last_login + 'Z').toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : 'Never'} - +
{u.email}
+
+ 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); + })()}
{!!u.must_change_password &&
âš  Must change password
}
diff --git a/frontend/src/contexts/SocketContext.jsx b/frontend/src/contexts/SocketContext.jsx index a33fac8..ad95e2b 100644 --- a/frontend/src/contexts/SocketContext.jsx +++ b/frontend/src/contexts/SocketContext.jsx @@ -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 ( diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index 75b1f66..1b66843 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -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]);