Skip to content
Snippets Groups Projects
Commit b9459db7 authored by Léopold Chappuis's avatar Léopold Chappuis Committed by Léopold Chappuis
Browse files

presence: implement frontend logic for user activity status

Users can now easily see whether another user is active or not.

Change-Id: I7df9811de1c5f2742b7c87ac245674def12d1243
parent b9a7baea
Branches
No related tags found
No related merge requests found
...@@ -18,11 +18,12 @@ ...@@ -18,11 +18,12 @@
import Check from '@mui/icons-material/Check' import Check from '@mui/icons-material/Check'
import RefreshOutlined from '@mui/icons-material/RefreshOutlined' import RefreshOutlined from '@mui/icons-material/RefreshOutlined'
import { Avatar, AvatarProps, Box, Dialog, Stack, Typography, useTheme } from '@mui/material' import { Avatar, AvatarProps, Box, Dialog, Stack, Typography, useTheme } from '@mui/material'
import { ReactNode, useCallback, useEffect, useState } from 'react' import { ReactNode, useCallback, useContext, useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuthContext } from '../contexts/AuthProvider' import { useAuthContext } from '../contexts/AuthProvider'
import { PresenceContext } from '../contexts/PresenceProvider'
import CallPermissionDenied from '../pages/CallPermissionDenied' import CallPermissionDenied from '../pages/CallPermissionDenied'
import { useUpdateAvatarMutation } from '../services/accountQueries' import { useUpdateAvatarMutation } from '../services/accountQueries'
import { useContactProfilePicture } from '../services/contactQueries' import { useContactProfilePicture } from '../services/contactQueries'
...@@ -61,7 +62,7 @@ export default function ConversationAvatar({ displayName, contactUri, presence, ...@@ -61,7 +62,7 @@ export default function ConversationAvatar({ displayName, contactUri, presence,
if (presence) { if (presence) {
return ( return (
<ConversationAvatarPresence> <ConversationAvatarPresence buddy={contactUri}>
<Avatar <Avatar
id={'avatar' + contactUri} id={'avatar' + contactUri}
{...props} {...props}
...@@ -83,7 +84,21 @@ export default function ConversationAvatar({ displayName, contactUri, presence, ...@@ -83,7 +84,21 @@ export default function ConversationAvatar({ displayName, contactUri, presence,
) )
} }
const ConversationAvatarPresence = ({ children }: { children: ReactNode }) => { const ConversationAvatarPresence = ({ children, buddy }: { children: ReactNode; buddy: string }) => {
const theme = useTheme()
const { getPresence, presence } = useContext(PresenceContext)
const [precenseState, setPrecenseState] = useState<number>(getPresence(buddy))
useEffect(() => {
setPrecenseState(getPresence(buddy))
}, [getPresence, presence, buddy])
if (!precenseState || precenseState === 0) {
return children
}
const presenceIconSize = '12px'
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', width: 'auto', height: 'auto' }}> <Box sx={{ display: 'flex', flexDirection: 'column', width: 'auto', height: 'auto' }}>
{children} {children}
...@@ -99,11 +114,11 @@ const ConversationAvatarPresence = ({ children }: { children: ReactNode }) => { ...@@ -99,11 +114,11 @@ const ConversationAvatarPresence = ({ children }: { children: ReactNode }) => {
> >
<Box <Box
sx={{ sx={{
width: '10px', width: presenceIconSize,
height: '10px', height: presenceIconSize,
backgroundColor: 'red', backgroundColor: '#33cc33',
borderRadius: '50%', borderRadius: '50%',
border: '2px solid', border: '2px solid ' + theme.ChatInterface.panelColor,
}} }}
/> />
</Box> </Box>
......
...@@ -519,7 +519,7 @@ const MemberInfo = ({ member, key, isAdmin }: MemberInfoProps) => { ...@@ -519,7 +519,7 @@ const MemberInfo = ({ member, key, isAdmin }: MemberInfoProps) => {
}} }}
> >
{' '} {' '}
<ConversationAvatar contactUri={member.contact.uri} displayName={bestName} /> <ConversationAvatar presence={true} contactUri={member.contact.uri} displayName={bestName} />
<Box maxWidth={getBestWidth} marginLeft="8px"> <Box maxWidth={getBestWidth} marginLeft="8px">
{bestName.length > 15 ? <EllipsisMiddle text={bestName} /> : bestName} {bestName.length > 15 ? <EllipsisMiddle text={bestName} /> : bestName}
</Box> </Box>
......
...@@ -137,6 +137,7 @@ const ConversationSummaryListItem = ({ conversationSummary }: ConversationSummar ...@@ -137,6 +137,7 @@ const ConversationSummaryListItem = ({ conversationSummary }: ConversationSummar
onContextMenu={contextMenuHandler.handleAnchorPosition} onContextMenu={contextMenuHandler.handleAnchorPosition}
icon={ icon={
<ConversationAvatar <ConversationAvatar
presence={true}
contactUri={ contactUri={
Number(conversationSummary.mode) === 0 && filteredMembers.length > 0 ? filteredMembers[0].contact.uri : '' Number(conversationSummary.mode) === 0 && filteredMembers.length > 0 ? filteredMembers[0].contact.uri : ''
} }
......
/*
* Copyright (C) 2022-2025 Savoir-faire Linux Inc.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
import { NewBuddyNotification, WebSocketMessageType } from 'jami-web-common'
import { createContext, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { useWebSocketContext } from '../contexts/WebSocketProvider'
interface IPresenceContext {
getPresence: (buddy: string) => number
presence: Map<string, number>
}
export const PresenceContext = createContext<IPresenceContext>({
getPresence: () => 0,
presence: new Map(),
})
interface PresenceProviderProps {
children: ReactNode
}
const PresenceProvider: React.FC<PresenceProviderProps> = ({ children }) => {
const [presence, setPresence] = useState<Map<string, number>>(new Map())
const webSocket = useWebSocketContext()
const getPresence = useCallback(
(buddy: string) => {
return presence.get(buddy) || 0
},
[presence],
)
useEffect(() => {
function handlePresence(data: NewBuddyNotification) {
setPresence((prev) => {
const newPresence = new Map(prev)
newPresence.set(data.buddyUri, data.status)
return newPresence
})
}
webSocket.bind(WebSocketMessageType.NewBuddyNotification, handlePresence)
return () => {
webSocket.unbind(WebSocketMessageType.NewBuddyNotification, handlePresence)
}
}, [webSocket])
const value = useMemo(() => ({ getPresence, presence }), [getPresence, presence])
return <PresenceContext.Provider value={value}>{children}</PresenceContext.Provider>
}
export default PresenceProvider
...@@ -42,6 +42,7 @@ import CallManagerProvider from './contexts/CallManagerProvider' ...@@ -42,6 +42,7 @@ import CallManagerProvider from './contexts/CallManagerProvider'
import ConversationProvider from './contexts/ConversationProvider' import ConversationProvider from './contexts/ConversationProvider'
import MessengerProvider from './contexts/MessengerProvider' import MessengerProvider from './contexts/MessengerProvider'
import MobileMenuStateProvider from './contexts/MobileMenuStateProvider' import MobileMenuStateProvider from './contexts/MobileMenuStateProvider'
import PresenceProvider from './contexts/PresenceProvider'
import UserMediaProvider from './contexts/UserMediaProvider' import UserMediaProvider from './contexts/UserMediaProvider'
import WebSocketProvider from './contexts/WebSocketProvider' import WebSocketProvider from './contexts/WebSocketProvider'
import { AdminConfigurationOptions } from './enums/adminConfigurationOptions' import { AdminConfigurationOptions } from './enums/adminConfigurationOptions'
...@@ -123,6 +124,7 @@ export const router = createBrowserRouter( ...@@ -123,6 +124,7 @@ export const router = createBrowserRouter(
element={ element={
<AuthProvider> <AuthProvider>
<WebSocketProvider> <WebSocketProvider>
<PresenceProvider>
<AccountDetailsListener> <AccountDetailsListener>
<UserMediaProvider> <UserMediaProvider>
<CallManagerProvider> <CallManagerProvider>
...@@ -130,6 +132,7 @@ export const router = createBrowserRouter( ...@@ -130,6 +132,7 @@ export const router = createBrowserRouter(
</CallManagerProvider> </CallManagerProvider>
</UserMediaProvider> </UserMediaProvider>
</AccountDetailsListener> </AccountDetailsListener>
</PresenceProvider>
</WebSocketProvider> </WebSocketProvider>
</AuthProvider> </AuthProvider>
} }
......
...@@ -23,8 +23,10 @@ import { ConversationRequestMetadata } from '../jamid/conversation-request-metad ...@@ -23,8 +23,10 @@ import { ConversationRequestMetadata } from '../jamid/conversation-request-metad
import { Jamid } from '../jamid/jamid.js' import { Jamid } from '../jamid/jamid.js'
import { WebSocketServer } from '../websocket/websocket-server.js' import { WebSocketServer } from '../websocket/websocket-server.js'
import { ContactService } from './ContactService.js' import { ContactService } from './ContactService.js'
import { PresenceService } from './PresenceService.js'
const jamid = Container.get(Jamid) const jamid = Container.get(Jamid)
const presenceService = Container.get(PresenceService)
const webSocketServer = Container.get(WebSocketServer) const webSocketServer = Container.get(WebSocketServer)
const contactService = Container.get(ContactService) const contactService = Container.get(ContactService)
...@@ -102,6 +104,9 @@ export class ConversationService { ...@@ -102,6 +104,9 @@ export class ConversationService {
if (member.uri === accountUri) { if (member.uri === accountUri) {
return return
} }
presenceService.subscribe(accountId, member.uri)
// Add usernames for conversation members // Add usernames for conversation members
try { try {
const data = await jamid.lookupAddress(member.uri, '', accountId) const data = await jamid.lookupAddress(member.uri, '', accountId)
......
/*
* Copyright (C) 2022-2025 Savoir-faire Linux Inc.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
import { Container, Service } from 'typedi'
import { Jamid } from '../jamid/jamid.js'
@Service()
export class PresenceService {
private activeSubscribers: Map<string, Set<string>> = new Map()
private jamid: Jamid | undefined = undefined
getJamid() {
if (!this.jamid) {
this.jamid = Container.get(Jamid)
}
return this.jamid
}
subscribe(accountId: string, buddyUri: string) {
this.getJamid().subscribeBuddy(accountId, buddyUri, true)
const set = this.activeSubscribers.get(accountId)
if (!set) {
this.activeSubscribers.set(accountId, new Set([buddyUri]))
return
}
this.activeSubscribers.set(accountId, set.add(buddyUri))
}
unsubscribeAll(accountId: string) {
const set = this.activeSubscribers.get(accountId)
if (!set) {
return
}
for (const uri of set) {
this.getJamid().subscribeBuddy(accountId, uri, false)
}
}
unsubscribe(accountId: string, buddyUri: string) {
this.getJamid().subscribeBuddy(accountId, buddyUri, false)
const set = this.activeSubscribers.get(accountId)
if (!set) {
return
}
set.delete(buddyUri)
}
}
...@@ -20,12 +20,15 @@ import { Duplex } from 'node:stream' ...@@ -20,12 +20,15 @@ import { Duplex } from 'node:stream'
import { WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common' import { WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common'
import log from 'loglevel' import log from 'loglevel'
import { Service } from 'typedi' import { Container, Service } from 'typedi'
import { URL } from 'whatwg-url' import { URL } from 'whatwg-url'
import * as WebSocket from 'ws' import * as WebSocket from 'ws'
import { PresenceService } from '../services/PresenceService.js'
import { verifyJwt } from '../utils/jwt.js' import { verifyJwt } from '../utils/jwt.js'
const presenceService = Container.get(PresenceService)
type WebSocketCallback<T extends WebSocketMessageType> = (accountId: string, data: WebSocketMessageTable[T]) => void type WebSocketCallback<T extends WebSocketMessageType> = (accountId: string, data: WebSocketMessageTable[T]) => void
type WebSocketCallbacks = { type WebSocketCallbacks = {
...@@ -89,6 +92,8 @@ export class WebSocketServer { ...@@ -89,6 +92,8 @@ export class WebSocketServer {
if (index !== -1) { if (index !== -1) {
accountSockets.splice(index, 1) accountSockets.splice(index, 1)
if (accountSockets.length === 0) { if (accountSockets.length === 0) {
// Unsubscribe from all presence updates when the last socket is closed
presenceService.unsubscribeAll(accountId)
this.sockets.delete(accountId) this.sockets.delete(accountId)
} }
} }
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment