Files
microdao-daarion/apps/web/src/hooks/usePresenceHeartbeat.ts
Apple 6bd769ef40 feat(city-map): Add 2D City Map with coordinates and agent presence
- Add migration 013_city_map_coordinates.sql with map coordinates, zones, and agents table
- Add /city/map API endpoint in city-service
- Add /city/agents and /city/agents/online endpoints
- Extend presence aggregator to include agents[] in snapshot
- Add AgentsSource for fetching agent data from DB
- Create CityMap component with interactive room tiles
- Add useCityMap hook for fetching map data
- Update useGlobalPresence to include agents
- Add map/list view toggle on /city page
- Add agent badges to room cards and map tiles
2025-11-27 07:00:47 -08:00

152 lines
4.3 KiB
TypeScript

'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])
}