| /* |
| * Copyright (C) 2022 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 { Box, ListItemButton, Stack, Typography } from '@mui/material'; |
| import dayjs from 'dayjs'; |
| import { IConversationSummary } from 'jami-web-common'; |
| import { QRCodeCanvas } from 'qrcode.react'; |
| import { useCallback, useContext, useMemo, useState } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { useNavigate } from 'react-router-dom'; |
| |
| import { useAuthContext } from '../contexts/AuthProvider'; |
| import { CallManagerContext } from '../contexts/CallManagerProvider'; |
| import { CallStatus, useCallContext } from '../contexts/CallProvider'; |
| import { useUrlParams } from '../hooks/useUrlParams'; |
| import { setRefreshFromSlice } from '../redux/appSlice'; |
| import { useAppDispatch } from '../redux/hooks'; |
| import { ConversationRouteParams } from '../router'; |
| import { getMessageCallText, getMessageMemberText } from '../utils/chatmessages'; |
| import { formatRelativeDate, formatTime } from '../utils/dates×'; |
| import ContextMenu, { ContextMenuHandler, useContextMenuHandler } from './ContextMenu'; |
| import ConversationAvatar from './ConversationAvatar'; |
| import { ConfirmationDialog, DialogContentList, InfosDialog, useDialogHandler } from './Dialog'; |
| import { PopoverListItemData } from './PopoverList'; |
| import { AudioCallIcon, CancelIcon, MessageIcon, PersonIcon, VideoCallIcon } from './SvgIcon'; |
| |
| type ConversationListItemProps = { |
| conversationSummary: IConversationSummary; |
| }; |
| |
| export default function ConversationListItem({ conversationSummary }: ConversationListItemProps) { |
| const { account } = useAuthContext(); |
| const { |
| urlParams: { conversationId: selectedConversationId }, |
| } = useUrlParams<ConversationRouteParams>(); |
| const contextMenuHandler = useContextMenuHandler(); |
| const callContext = useCallContext(true); |
| const { callData } = useContext(CallManagerContext); |
| const { t, i18n } = useTranslation(); |
| const navigate = useNavigate(); |
| |
| const conversationId = conversationSummary.id; |
| const isSelected = conversationId === selectedConversationId; |
| |
| const onClick = useCallback(() => { |
| if (conversationId) { |
| navigate(`/conversation/${conversationId}`); |
| } |
| }, [navigate, conversationId]); |
| |
| const timeIndicator = useMemo(() => { |
| const message = conversationSummary.lastMessage; |
| const time = dayjs.unix(Number(message.timestamp)); |
| if (time.isToday()) { |
| return formatTime(time, i18n); |
| } else { |
| return formatRelativeDate(time, i18n); |
| } |
| }, [conversationSummary, i18n]); |
| |
| const lastMessageText = useMemo(() => { |
| if (!callContext || !callData || callData.conversationId !== conversationSummary.id) { |
| const message = conversationSummary.lastMessage; |
| switch (message.type) { |
| case 'initial': { |
| return t('message_swarm_created'); |
| } |
| case 'application/data-transfer+json': { |
| return message.fileId; |
| } |
| case 'application/call-history+json': { |
| const isAccountMessage = message.author === account.getUri(); |
| return getMessageCallText(isAccountMessage, message, i18n); |
| } |
| case 'member': { |
| return getMessageMemberText(message, i18n); |
| } |
| case 'text/plain': { |
| return message.body; |
| } |
| default: { |
| console.error(`${ConversationListItem.name} received an unexpected lastMessage type: ${message.type}`); |
| return ''; |
| } |
| } |
| } |
| |
| if (callContext.callStatus === CallStatus.InCall) { |
| return callContext.isAudioOn ? t('ongoing_call_unmuted') : t('ongoing_call_muted'); |
| } |
| |
| if (callContext.callStatus === CallStatus.Connecting) { |
| return t('connecting_call'); |
| } |
| |
| return callContext.callRole === 'caller' ? t('outgoing_call') : t('incoming_call'); |
| }, [account, conversationSummary, callContext, callData, t, i18n]); |
| |
| const conversationName = useMemo( |
| () => conversationSummary.title || conversationSummary.membersNames.join(', ') || account.getDisplayName(), |
| [account, conversationSummary] |
| ); |
| |
| return ( |
| <Box> |
| <ConversationMenu |
| conversationId={conversationId} |
| conversationName={conversationName} |
| onMessageClick={onClick} |
| isSelected={isSelected} |
| contextMenuProps={contextMenuHandler.props} |
| /> |
| <ListItemButton alignItems="flex-start" selected={isSelected} onClick={onClick}> |
| <Stack direction="row" spacing="10px"> |
| <ConversationAvatar displayName={conversationName} /> |
| <Stack> |
| <Typography variant="body1">{conversationName}</Typography> |
| <Stack direction="row" spacing="5px"> |
| <Typography variant="body2" fontWeight={isSelected ? 'bold' : 'normal'}> |
| {timeIndicator} |
| </Typography> |
| <Typography variant="body2">{lastMessageText}</Typography> |
| </Stack> |
| </Stack> |
| </Stack> |
| </ListItemButton> |
| </Box> |
| ); |
| } |
| |
| interface ConversationMenuProps { |
| conversationId: string; |
| conversationName: string; |
| onMessageClick: () => void; |
| isSelected: boolean; |
| contextMenuProps: ContextMenuHandler['props']; |
| } |
| |
| const ConversationMenu = ({ |
| conversationId, |
| conversationName, |
| onMessageClick, |
| isSelected, |
| contextMenuProps, |
| }: ConversationMenuProps) => { |
| const { t } = useTranslation(); |
| const { startCall } = useContext(CallManagerContext); |
| const [isSwarm] = useState(true); |
| |
| const detailsDialogHandler = useDialogHandler(); |
| const RemoveConversationDialogHandler = useDialogHandler(); |
| |
| const navigate = useNavigate(); |
| |
| const menuOptions: PopoverListItemData[] = useMemo( |
| () => [ |
| { |
| label: t('conversation_message'), |
| Icon: MessageIcon, |
| onClick: onMessageClick, |
| }, |
| { |
| label: t('conversation_start_audiocall'), |
| Icon: AudioCallIcon, |
| onClick: () => { |
| if (conversationId) { |
| startCall({ |
| conversationId, |
| role: 'caller', |
| }); |
| } |
| }, |
| }, |
| { |
| label: t('conversation_start_videocall'), |
| Icon: VideoCallIcon, |
| onClick: () => { |
| if (conversationId) { |
| startCall({ |
| conversationId, |
| role: 'caller', |
| withVideoOn: true, |
| }); |
| } |
| }, |
| }, |
| ...(isSelected |
| ? [ |
| { |
| label: t('conversation_close'), |
| Icon: CancelIcon, |
| onClick: () => { |
| navigate(`/`); |
| }, |
| }, |
| ] |
| : []), |
| { |
| label: t('conversation_details'), |
| Icon: PersonIcon, |
| onClick: () => { |
| detailsDialogHandler.openDialog(); |
| }, |
| }, |
| { |
| label: t('conversation_delete'), |
| Icon: CancelIcon, |
| onClick: () => { |
| RemoveConversationDialogHandler.openDialog(); |
| }, |
| }, |
| ], |
| [ |
| navigate, |
| onMessageClick, |
| isSelected, |
| detailsDialogHandler, |
| RemoveConversationDialogHandler, |
| t, |
| startCall, |
| conversationId, |
| ] |
| ); |
| |
| return ( |
| <> |
| <ContextMenu {...contextMenuProps} items={menuOptions} /> |
| |
| <DetailsDialog |
| {...detailsDialogHandler.props} |
| conversationId={conversationId} |
| conversationName={conversationName} |
| isSwarm={isSwarm} |
| /> |
| |
| <RemoveConversationDialog {...RemoveConversationDialogHandler.props} conversationId={conversationId} /> |
| </> |
| ); |
| }; |
| |
| interface DetailsDialogProps { |
| conversationId: string; |
| conversationName: string; |
| open: boolean; |
| onClose: () => void; |
| isSwarm: boolean; |
| } |
| |
| const DetailsDialog = ({ conversationId, conversationName, open, onClose, isSwarm }: DetailsDialogProps) => { |
| const { t } = useTranslation(); |
| const items = useMemo( |
| () => [ |
| { |
| label: t('conversation_details_name'), |
| value: conversationName, |
| }, |
| { |
| label: t('conversation_details_identifier'), |
| value: conversationId, |
| }, |
| { |
| label: t('conversation_details_qr_code'), |
| value: <QRCodeCanvas size={80} value={`${conversationId}`} />, |
| }, |
| { |
| label: t('conversation_details_is_swarm'), |
| value: isSwarm ? t('conversation_details_is_swarm_true') : t('conversation_details_is_swarm_false'), |
| }, |
| ], |
| [conversationId, conversationName, isSwarm, t] |
| ); |
| return ( |
| <InfosDialog |
| open={open} |
| onClose={onClose} |
| icon={<ConversationAvatar sx={{ width: 'inherit', height: 'inherit' }} displayName={conversationName} />} |
| title={conversationName} |
| content={<DialogContentList title={t('conversation_details_informations')} items={items} />} |
| /> |
| ); |
| }; |
| |
| interface RemoveConversationDialogProps { |
| conversationId: string; |
| open: boolean; |
| onClose: () => void; |
| } |
| |
| const RemoveConversationDialog = ({ conversationId, open, onClose }: RemoveConversationDialogProps) => { |
| const { axiosInstance } = useAuthContext(); |
| const { t } = useTranslation(); |
| const dispatch = useAppDispatch(); |
| |
| const remove = async () => { |
| const controller = new AbortController(); |
| try { |
| await axiosInstance.delete(`/conversations/${conversationId}`, { |
| signal: controller.signal, |
| }); |
| dispatch(setRefreshFromSlice()); |
| } catch (e) { |
| console.error(`Error removing conversation : `, e); |
| dispatch(setRefreshFromSlice()); |
| } |
| onClose(); |
| }; |
| |
| return ( |
| <ConfirmationDialog |
| open={open} |
| onClose={onClose} |
| title={t('dialog_confirm_title_default')} |
| content={t('conversation_ask_confirm_remove')} |
| onConfirm={remove} |
| confirmButtonText={t('conversation_confirm_remove')} |
| /> |
| ); |
| }; |