feat: Add presence heartbeat for Matrix online status
- matrix-gateway: POST /internal/matrix/presence/online endpoint - usePresenceHeartbeat hook with activity tracking - Auto away after 5 min inactivity - Offline on page close/visibility change - Integrated in MatrixChatRoom component
This commit is contained in:
150
apps/web/src/hooks/usePresenceHeartbeat.ts
Normal file
150
apps/web/src/hooks/usePresenceHeartbeat.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
type PresenceStatus = 'online' | 'unavailable' | 'offline'
|
||||
|
||||
interface UsePresenceHeartbeatOptions {
|
||||
matrixUserId: string | null
|
||||
accessToken: string | null
|
||||
intervalMs?: number
|
||||
awayAfterMs?: number // Time of inactivity before setting "unavailable"
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to send presence heartbeats to Matrix via gateway.
|
||||
*
|
||||
* - Sends "online" status on mount and every intervalMs
|
||||
* - Optionally tracks user activity and sends "unavailable" after inactivity
|
||||
* - Sends "offline" on unmount
|
||||
*/
|
||||
export function usePresenceHeartbeat({
|
||||
matrixUserId,
|
||||
accessToken,
|
||||
intervalMs = 30000,
|
||||
awayAfterMs = 5 * 60 * 1000, // 5 minutes
|
||||
enabled = true,
|
||||
}: UsePresenceHeartbeatOptions) {
|
||||
const lastActivityRef = useRef(Date.now())
|
||||
const currentStatusRef = useRef<PresenceStatus>('online')
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const sendPresence = useCallback(async (status: PresenceStatus) => {
|
||||
if (!matrixUserId || !accessToken) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/internal/matrix/presence/online', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
matrix_user_id: matrixUserId,
|
||||
access_token: accessToken,
|
||||
status,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
currentStatusRef.current = status
|
||||
console.debug(`[Presence] Set to ${status}`)
|
||||
} else {
|
||||
console.warn('[Presence] Failed to set status:', await response.text())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Presence] Error sending heartbeat:', error)
|
||||
}
|
||||
}, [matrixUserId, accessToken])
|
||||
|
||||
// Track user activity
|
||||
useEffect(() => {
|
||||
if (!enabled || !matrixUserId || !accessToken) return
|
||||
|
||||
const updateActivity = () => {
|
||||
lastActivityRef.current = Date.now()
|
||||
|
||||
// If was away, come back online
|
||||
if (currentStatusRef.current === 'unavailable') {
|
||||
sendPresence('online')
|
||||
}
|
||||
}
|
||||
|
||||
const events = ['mousedown', 'keydown', 'touchstart', 'scroll']
|
||||
events.forEach(event => {
|
||||
window.addEventListener(event, updateActivity, { passive: true })
|
||||
})
|
||||
|
||||
return () => {
|
||||
events.forEach(event => {
|
||||
window.removeEventListener(event, updateActivity)
|
||||
})
|
||||
}
|
||||
}, [enabled, matrixUserId, accessToken, sendPresence])
|
||||
|
||||
// Main heartbeat loop
|
||||
useEffect(() => {
|
||||
if (!enabled || !matrixUserId || !accessToken) return
|
||||
|
||||
const heartbeat = async () => {
|
||||
const now = Date.now()
|
||||
const timeSinceActivity = now - lastActivityRef.current
|
||||
|
||||
// Check if user is inactive
|
||||
if (awayAfterMs > 0 && timeSinceActivity > awayAfterMs) {
|
||||
if (currentStatusRef.current !== 'unavailable') {
|
||||
await sendPresence('unavailable')
|
||||
}
|
||||
} else {
|
||||
await sendPresence('online')
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial heartbeat
|
||||
heartbeat()
|
||||
|
||||
// Set up interval
|
||||
intervalRef.current = setInterval(heartbeat, intervalMs)
|
||||
|
||||
// Cleanup: send offline on unmount
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
|
||||
// Send offline (fire and forget)
|
||||
if (matrixUserId && accessToken) {
|
||||
fetch('/api/internal/matrix/presence/online', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
matrix_user_id: matrixUserId,
|
||||
access_token: accessToken,
|
||||
status: 'offline',
|
||||
}),
|
||||
keepalive: true, // Ensure request completes even on page unload
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [enabled, matrixUserId, accessToken, intervalMs, awayAfterMs, sendPresence])
|
||||
|
||||
// Also send offline on page visibility change
|
||||
useEffect(() => {
|
||||
if (!enabled || !matrixUserId || !accessToken) return
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
sendPresence('unavailable')
|
||||
} else {
|
||||
lastActivityRef.current = Date.now()
|
||||
sendPresence('online')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [enabled, matrixUserId, accessToken, sendPresence])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user