diff --git a/client/src/components/ConversationAvatar.tsx b/client/src/components/ConversationAvatar.tsx index 4b48d453e3690e3df333c401a1e11758eae9ce05..17745fa0da5785ef0fd41f4b3c74291919cb03e6 100644 --- a/client/src/components/ConversationAvatar.tsx +++ b/client/src/components/ConversationAvatar.tsx @@ -18,11 +18,12 @@ import Check from '@mui/icons-material/Check' import RefreshOutlined from '@mui/icons-material/RefreshOutlined' 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 { useTranslation } from 'react-i18next' import { useAuthContext } from '../contexts/AuthProvider' +import { PresenceContext } from '../contexts/PresenceProvider' import CallPermissionDenied from '../pages/CallPermissionDenied' import { useUpdateAvatarMutation } from '../services/accountQueries' import { useContactProfilePicture } from '../services/contactQueries' @@ -61,7 +62,7 @@ export default function ConversationAvatar({ displayName, contactUri, presence, if (presence) { return ( - <ConversationAvatarPresence> + <ConversationAvatarPresence buddy={contactUri}> <Avatar id={'avatar' + contactUri} {...props} @@ -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 ( <Box sx={{ display: 'flex', flexDirection: 'column', width: 'auto', height: 'auto' }}> {children} @@ -99,11 +114,11 @@ const ConversationAvatarPresence = ({ children }: { children: ReactNode }) => { > <Box sx={{ - width: '10px', - height: '10px', - backgroundColor: 'red', + width: presenceIconSize, + height: presenceIconSize, + backgroundColor: '#33cc33', borderRadius: '50%', - border: '2px solid', + border: '2px solid ' + theme.ChatInterface.panelColor, }} /> </Box> diff --git a/client/src/components/ConversationPreferences.tsx b/client/src/components/ConversationPreferences.tsx index a3c48e9b8705a3b2c5b9311498342b7e8abd4444..05268bcbdc75b43cf32472dc6977db22682bfe2f 100644 --- a/client/src/components/ConversationPreferences.tsx +++ b/client/src/components/ConversationPreferences.tsx @@ -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"> {bestName.length > 15 ? <EllipsisMiddle text={bestName} /> : bestName} </Box> diff --git a/client/src/components/ConversationSummaryList.tsx b/client/src/components/ConversationSummaryList.tsx index 45f762d15983c0330b487ddd93d01a1c27490095..5026d7026bf8287bc5a1f08b68cdb64cbcd087e8 100644 --- a/client/src/components/ConversationSummaryList.tsx +++ b/client/src/components/ConversationSummaryList.tsx @@ -137,6 +137,7 @@ const ConversationSummaryListItem = ({ conversationSummary }: ConversationSummar onContextMenu={contextMenuHandler.handleAnchorPosition} icon={ <ConversationAvatar + presence={true} contactUri={ Number(conversationSummary.mode) === 0 && filteredMembers.length > 0 ? filteredMembers[0].contact.uri : '' } diff --git a/client/src/contexts/PresenceProvider.tsx b/client/src/contexts/PresenceProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..38be287ea0ff65c9f412779d8c2c3fc3a22a8466 --- /dev/null +++ b/client/src/contexts/PresenceProvider.tsx @@ -0,0 +1,69 @@ +/* + * 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 diff --git a/client/src/router.tsx b/client/src/router.tsx index 036422f9a090a52d5b3a6e30247904e5e40370ae..b8dfe5a51b6769687f2d4b79307a299b08e9573f 100644 --- a/client/src/router.tsx +++ b/client/src/router.tsx @@ -42,6 +42,7 @@ import CallManagerProvider from './contexts/CallManagerProvider' import ConversationProvider from './contexts/ConversationProvider' import MessengerProvider from './contexts/MessengerProvider' import MobileMenuStateProvider from './contexts/MobileMenuStateProvider' +import PresenceProvider from './contexts/PresenceProvider' import UserMediaProvider from './contexts/UserMediaProvider' import WebSocketProvider from './contexts/WebSocketProvider' import { AdminConfigurationOptions } from './enums/adminConfigurationOptions' @@ -123,13 +124,15 @@ export const router = createBrowserRouter( element={ <AuthProvider> <WebSocketProvider> - <AccountDetailsListener> - <UserMediaProvider> - <CallManagerProvider> - <Outlet /> - </CallManagerProvider> - </UserMediaProvider> - </AccountDetailsListener> + <PresenceProvider> + <AccountDetailsListener> + <UserMediaProvider> + <CallManagerProvider> + <Outlet /> + </CallManagerProvider> + </UserMediaProvider> + </AccountDetailsListener> + </PresenceProvider> </WebSocketProvider> </AuthProvider> } diff --git a/server/src/services/ConversationService.ts b/server/src/services/ConversationService.ts index 82ad793b8726ffd357b4559abd604cc234f15e37..de8f9a2a00ece2a4708e0dd9e6003954eddbbe75 100644 --- a/server/src/services/ConversationService.ts +++ b/server/src/services/ConversationService.ts @@ -23,8 +23,10 @@ import { ConversationRequestMetadata } from '../jamid/conversation-request-metad import { Jamid } from '../jamid/jamid.js' import { WebSocketServer } from '../websocket/websocket-server.js' import { ContactService } from './ContactService.js' +import { PresenceService } from './PresenceService.js' const jamid = Container.get(Jamid) +const presenceService = Container.get(PresenceService) const webSocketServer = Container.get(WebSocketServer) const contactService = Container.get(ContactService) @@ -102,6 +104,9 @@ export class ConversationService { if (member.uri === accountUri) { return } + + presenceService.subscribe(accountId, member.uri) + // Add usernames for conversation members try { const data = await jamid.lookupAddress(member.uri, '', accountId) diff --git a/server/src/services/PresenceService.ts b/server/src/services/PresenceService.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5934329eb11886ee8af5a71c119d5b45d9ff47b --- /dev/null +++ b/server/src/services/PresenceService.ts @@ -0,0 +1,63 @@ +/* + * 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) + } +} diff --git a/server/src/websocket/websocket-server.ts b/server/src/websocket/websocket-server.ts index ba100b9351e6d69c1775a098fc6a0623a021bbdd..5fb961b721f91544d0ff1ec5e21a66f4cf23765a 100644 --- a/server/src/websocket/websocket-server.ts +++ b/server/src/websocket/websocket-server.ts @@ -20,12 +20,15 @@ import { Duplex } from 'node:stream' import { WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common' import log from 'loglevel' -import { Service } from 'typedi' +import { Container, Service } from 'typedi' import { URL } from 'whatwg-url' import * as WebSocket from 'ws' +import { PresenceService } from '../services/PresenceService.js' import { verifyJwt } from '../utils/jwt.js' +const presenceService = Container.get(PresenceService) + type WebSocketCallback<T extends WebSocketMessageType> = (accountId: string, data: WebSocketMessageTable[T]) => void type WebSocketCallbacks = { @@ -89,6 +92,8 @@ export class WebSocketServer { if (index !== -1) { accountSockets.splice(index, 1) if (accountSockets.length === 0) { + // Unsubscribe from all presence updates when the last socket is closed + presenceService.unsubscribeAll(accountId) this.sockets.delete(accountId) } }