v0.6.5 various bug fixes
This commit is contained in:
@@ -7,7 +7,7 @@ TZ=UTC
|
|||||||
# Copy this file to .env and customize
|
# Copy this file to .env and customize
|
||||||
|
|
||||||
# Image version to run (set by build.sh, or use 'latest')
|
# 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)
|
# Default admin credentials (used on FIRST RUN only)
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.6.5",
|
"version": "0.6.6",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ io.on('connection', (socket) => {
|
|||||||
if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set());
|
if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set());
|
||||||
onlineUsers.get(userId).add(socket.id);
|
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
|
// Broadcast online status
|
||||||
io.emit('user:online', { userId });
|
io.emit('user:online', { userId });
|
||||||
|
|
||||||
@@ -200,6 +203,7 @@ io.on('connection', (socket) => {
|
|||||||
title: senderName,
|
title: senderName,
|
||||||
body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100),
|
body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100),
|
||||||
url: '/',
|
url: '/',
|
||||||
|
groupId,
|
||||||
badge: 1,
|
badge: 1,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
@@ -340,6 +344,7 @@ io.on('connection', (socket) => {
|
|||||||
onlineUsers.get(userId).delete(socket.id);
|
onlineUsers.get(userId).delete(socket.id);
|
||||||
if (onlineUsers.get(userId).size === 0) {
|
if (onlineUsers.get(userId).size === 0) {
|
||||||
onlineUsers.delete(userId);
|
onlineUsers.delete(userId);
|
||||||
|
getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId);
|
||||||
io.emit('user:offline', { userId });
|
io.emit('user:offline', { userId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,10 +182,10 @@ function initDb() {
|
|||||||
console.log('[DB] Migration: added direct_peer2_id column');
|
console.log('[DB] Migration: added direct_peer2_id column');
|
||||||
} catch (e) { /* column already exists */ }
|
} catch (e) { /* column already exists */ }
|
||||||
|
|
||||||
// Migration: last_login timestamp per user
|
// Migration: last_online timestamp per user
|
||||||
try {
|
try {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN last_login TEXT");
|
db.exec("ALTER TABLE users ADD COLUMN last_online TEXT");
|
||||||
console.log('[DB] Migration: added last_login column');
|
console.log('[DB] Migration: added last_online column');
|
||||||
} catch (e) { /* column already exists */ }
|
} catch (e) { /* column already exists */ }
|
||||||
|
|
||||||
// Migration: help_dismissed preference per user
|
// Migration: help_dismissed preference per user
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ router.post('/login', (req, res) => {
|
|||||||
const valid = bcrypt.compareSync(password, user.password);
|
const valid = bcrypt.compareSync(password, user.password);
|
||||||
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
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 token = generateToken(user.id);
|
||||||
const ua = req.headers['user-agent'] || '';
|
const ua = req.headers['user-agent'] || '';
|
||||||
const device = setActiveSession(user.id, token, ua); // displaces prior session on same device class
|
const device = setActiveSession(user.id, token, ua); // displaces prior session on same device class
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function getDefaultPassword(db) {
|
|||||||
router.get('/', authMiddleware, adminMiddleware, (req, res) => {
|
router.get('/', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const users = db.prepare(`
|
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'
|
FROM users WHERE status != 'deleted'
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`).all();
|
`).all();
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.6.5}"
|
VERSION="${1:-0.6.6}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
38
data/help.md
38
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
|
- **Shift + Enter** adds a new line without sending
|
||||||
- Tap the **+** button to attach a photo or emoji
|
- 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
|
### Mentioning Someone
|
||||||
Type **@** followed by the person's name to mention them. Select from the dropdown that appears. Mentioned users receive a notification.
|
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
|
## 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
|
2. Select one user from the list
|
||||||
3. Click **Start Conversation**
|
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:
|
To create a group conversation:
|
||||||
|
|
||||||
1. Click the **pencil / new chat** icon
|
1. Click the **new chat** icon
|
||||||
2. Select two or more users
|
2. Select two or more users from the
|
||||||
3. Enter a **Group Name**
|
3. Enter a **Message Name**
|
||||||
4. Click **Create**
|
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:
|
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
|
- Add an **about me** note
|
||||||
- Upload a **profile photo**
|
- Upload a **profile photo**
|
||||||
- Change your **password**
|
- 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:
|
You can set a personal display name for any group that only you will see:
|
||||||
|
|
||||||
1. Open the group
|
1. Open the message
|
||||||
2. Click the **ⓘ info** icon in the top bar
|
2. Click the **message info** icon in the top right
|
||||||
3. Enter your custom name under **Your custom name**
|
3. Enter your custom name under **Your custom name**
|
||||||
4. Click **Save**
|
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:
|
Admins can access **Settings** from the user menu to configure:
|
||||||
|
|
||||||
- App name and logo
|
- Branding a new app name and logo
|
||||||
- Default user password
|
- Set new user password
|
||||||
- Notification preferences
|
- Notification preferences
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -98,8 +100,4 @@ Admins can access **Settings** from the user menu to configure:
|
|||||||
|
|
||||||
- 🌙 Toggle **dark mode** from the user menu
|
- 🌙 Toggle **dark mode** from the user menu
|
||||||
- 🔔 Enable **push notifications** when prompted to receive alerts when the app is closed
|
- 🔔 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
|
- 📱 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.*
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.6.5",
|
"version": "0.6.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -27,36 +27,53 @@ self.addEventListener('fetch', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track badge count in SW
|
// Track badge count in SW scope
|
||||||
let badgeCount = 0;
|
let badgeCount = 0;
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
if (!event.data) return;
|
if (!event.data) return;
|
||||||
const data = event.data.json();
|
|
||||||
|
let data = {};
|
||||||
|
try { data = event.data.json(); } catch (e) { return; }
|
||||||
|
|
||||||
badgeCount++;
|
badgeCount++;
|
||||||
|
|
||||||
// Update app badge (supported on Android Chrome and some desktop)
|
// Update app badge
|
||||||
if (navigator.setAppBadge) {
|
if (self.navigator && self.navigator.setAppBadge) {
|
||||||
navigator.setAppBadge(badgeCount).catch(() => {});
|
self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
// Check if app is currently visible — if so, skip the notification
|
||||||
self.registration.showNotification(data.title || 'New Message', {
|
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 || '',
|
body: data.body || '',
|
||||||
icon: '/icons/icon-192.png',
|
icon: '/icons/icon-192.png',
|
||||||
badge: '/icons/icon-192.png',
|
badge: '/icons/icon-192-maskable.png',
|
||||||
data: { url: data.url || '/' },
|
data: { url: data.url || '/' },
|
||||||
tag: 'jama-message', // replaces previous notification instead of stacking
|
// Use unique tag per group so notifications group by conversation
|
||||||
renotify: true, // still vibrate/sound even if replacing
|
tag: data.groupId ? `jama-group-${data.groupId}` : 'jama-message',
|
||||||
})
|
renotify: true,
|
||||||
);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
event.waitUntil(showNotification);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
badgeCount = 0;
|
badgeCount = 0;
|
||||||
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
if (self.navigator && self.navigator.clearAppBadge) {
|
||||||
|
self.navigator.clearAppBadge().catch(() => {});
|
||||||
|
}
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
const url = event.notification.data?.url || '/';
|
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) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'CLEAR_BADGE') {
|
if (event.data?.type === 'CLEAR_BADGE') {
|
||||||
badgeCount = 0;
|
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.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>}
|
{!!u.is_default_admin && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Default Admin</span>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.email}</div>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.email}</span>
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 1 }}>
|
||||||
<span style={{ color: 'var(--text-tertiary)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
Last online: {(() => {
|
||||||
Last login: {u.last_login ? new Date(u.last_login + 'Z').toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : 'Never'}
|
if (!u.last_online) return 'Never';
|
||||||
</span>
|
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>
|
</div>
|
||||||
{!!u.must_change_password && <div className="text-xs" style={{ color: 'var(--warning)' }}>⚠ Must change password</div>}
|
{!!u.must_change_password && <div className="text-xs" style={{ color: 'var(--warning)' }}>⚠ Must change password</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,16 @@ export function SocketProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
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;
|
socketRef.current = socket;
|
||||||
|
|
||||||
socket.on('connect', () => {
|
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: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; }));
|
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]);
|
}, [user?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -63,39 +63,54 @@ export default function Chat() {
|
|||||||
|
|
||||||
useEffect(() => { loadGroups(); }, [loadGroups]);
|
useEffect(() => { loadGroups(); }, [loadGroups]);
|
||||||
|
|
||||||
// Register push subscription
|
// Register / refresh push subscription
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||||
(async () => {
|
|
||||||
|
const registerPush = async () => {
|
||||||
try {
|
try {
|
||||||
|
const permission = Notification.permission;
|
||||||
|
if (permission === 'denied') return;
|
||||||
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
const reg = await navigator.serviceWorker.ready;
|
||||||
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
|
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
|
||||||
const existing = await reg.pushManager.getSubscription();
|
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||||
if (existing) {
|
|
||||||
// Re-register to keep subscription fresh
|
let sub = await reg.pushManager.getSubscription();
|
||||||
await fetch('/api/push/subscribe', {
|
|
||||||
method: 'POST',
|
if (!sub) {
|
||||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
|
// First time or subscription was lost — request permission then subscribe
|
||||||
body: JSON.stringify(existing.toJSON())
|
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;
|
// Always re-register subscription with the server (keeps it fresh on mobile)
|
||||||
const sub = await reg.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
|
||||||
});
|
|
||||||
await fetch('/api/push/subscribe', {
|
await fetch('/api/push/subscribe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||||
body: JSON.stringify(sub.toJSON())
|
body: JSON.stringify(sub.toJSON()),
|
||||||
});
|
});
|
||||||
console.log('[Push] Subscribed');
|
console.log('[Push] Subscription registered');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Push] Subscription failed:', e.message);
|
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
|
// Socket message events to update group previews
|
||||||
@@ -113,10 +128,13 @@ export default function Chat() {
|
|||||||
privateGroups: prev.privateGroups.map(updateGroup),
|
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;
|
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 => {
|
setUnreadGroups(prev => {
|
||||||
if (msg.group_id === activeGroupId) return prev;
|
if (groupIsActive && !windowHidden) return prev; // visible & active: no badge
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(msg.group_id, (next.get(msg.group_id) || 0) + 1);
|
next.set(msg.group_id, (next.get(msg.group_id) || 0) + 1);
|
||||||
return next;
|
return next;
|
||||||
@@ -173,12 +191,26 @@ export default function Chat() {
|
|||||||
socket.on('group:deleted', handleGroupDeleted);
|
socket.on('group:deleted', handleGroupDeleted);
|
||||||
socket.on('group:updated', handleGroupUpdated);
|
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 () => {
|
return () => {
|
||||||
socket.off('message:new', handleNewMsg);
|
socket.off('message:new', handleNewMsg);
|
||||||
socket.off('notification:new', handleNotification);
|
socket.off('notification:new', handleNotification);
|
||||||
socket.off('group:new', handleGroupNew);
|
socket.off('group:new', handleGroupNew);
|
||||||
socket.off('group:deleted', handleGroupDeleted);
|
socket.off('group:deleted', handleGroupDeleted);
|
||||||
socket.off('group:updated', handleGroupUpdated);
|
socket.off('group:updated', handleGroupUpdated);
|
||||||
|
socket.off('connect', handleReconnect);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
};
|
};
|
||||||
}, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
|
}, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user