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:
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const GATEWAY_URL = process.env.MATRIX_GATEWAY_URL || 'http://localhost:7025'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.matrix_user_id || !body.access_token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: matrix_user_id, access_token' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Proxy to matrix-gateway
|
||||
const response = await fetch(`${GATEWAY_URL}/internal/matrix/presence/online`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
matrix_user_id: body.matrix_user_id,
|
||||
access_token: body.access_token,
|
||||
status: body.status || 'online',
|
||||
status_msg: body.status_msg,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('[Presence Proxy] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to set presence' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MatrixRestClient, createMatrixClient, ChatMessage as MatrixChatMessage,
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { getAccessToken } from '@/lib/auth'
|
||||
import { usePresenceHeartbeat } from '@/hooks/usePresenceHeartbeat'
|
||||
|
||||
interface MatrixChatRoomProps {
|
||||
roomSlug: string
|
||||
@@ -52,6 +53,15 @@ export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
|
||||
const [onlineUsers, setOnlineUsers] = useState<Map<string, 'online' | 'offline' | 'unavailable'>>(new Map())
|
||||
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set())
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Presence heartbeat - keeps user "online" in Matrix
|
||||
usePresenceHeartbeat({
|
||||
matrixUserId: bootstrap?.matrix_user_id ?? null,
|
||||
accessToken: bootstrap?.matrix_access_token ?? null,
|
||||
intervalMs: 30000, // Every 30 seconds
|
||||
awayAfterMs: 5 * 60 * 1000, // 5 minutes of inactivity
|
||||
enabled: status === 'online',
|
||||
})
|
||||
|
||||
// Scroll to bottom when new messages arrive
|
||||
const scrollToBottom = useCallback(() => {
|
||||
|
||||
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