blob: 6b2fad87c59f2f4205240c52ec876a400bbc7940 [file] [log] [blame]
/*
* 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,
Chip,
Divider,
List,
ListItemButton,
ListItemText,
Stack,
Theme,
Tooltip,
Typography,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { Dayjs } from 'dayjs';
import { Account, Contact, Message } from 'jami-web-common';
import { ReactElement, ReactNode, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from '../dayjsInitializer';
import { EmojiButton, MoreButton, ReplyMessageButton } from './Button';
import ConversationAvatar from './ConversationAvatar';
import {
ArrowLeftCurved,
ArrowLeftDown,
ArrowRightUp,
OppositeArrowsIcon,
TrashBinIcon,
TwoSheetsIcon,
} from './SvgIcon';
type MessagePosition = 'start' | 'end';
const notificationMessageTypes = ['initial', 'member'] as const;
type NotificationMessageType = typeof notificationMessageTypes[number];
const checkIsNotificationMessageType = (type: Message['type'] | undefined): type is NotificationMessageType => {
return notificationMessageTypes.includes(type as NotificationMessageType);
};
const invisibleMessageTypes = ['application/update-profile', 'merge', 'vote'] as const;
type InvisibleMessageType = typeof invisibleMessageTypes[number];
const checkIsInvisibleMessageType = (type: Message['type'] | undefined): type is InvisibleMessageType => {
return invisibleMessageTypes.includes(type as InvisibleMessageType);
};
const userMessageTypes = ['text/plain', 'application/data-transfer+json', 'application/call-history+json'] as const;
type UserMessageType = typeof userMessageTypes[number];
const checkIsUserMessageType = (type: Message['type'] | undefined): type is UserMessageType => {
return userMessageTypes.includes(type as UserMessageType);
};
const checkShowsTime = (time: Dayjs, previousTime: Dayjs) => {
return !previousTime.isSame(time) && !time.isBetween(previousTime, previousTime?.add(1, 'minute'));
};
const findPreviousVisibleMessage = (messages: Message[], messageIndex: number) => {
for (let i = messageIndex + 1; i < messages.length; ++i) {
const message = messages[i];
if (!checkIsInvisibleMessageType(message?.type)) {
return message;
}
}
};
const findNextVisibleMessage = (messages: Message[], messageIndex: number) => {
for (let i = messageIndex - 1; i >= 0; --i) {
const message = messages[i];
if (!checkIsInvisibleMessageType(message?.type)) {
return message;
}
}
};
const avatarSize = '22px';
const spacingBetweenAvatarAndBubble = '10px';
const bubblePadding = '16px';
interface MessageCallProps {
message: Message;
isAccountMessage: boolean;
isFirstOfGroup: boolean;
isLastOfGroup: boolean;
}
const MessageCall = ({ message, isAccountMessage, isFirstOfGroup, isLastOfGroup }: MessageCallProps) => {
const position = isAccountMessage ? 'end' : 'start';
const { t } = useTranslation();
const { bubbleColor, Icon, text, textColor } = useMemo(() => {
const callDuration = dayjs.duration(parseInt(message?.duration || ''));
if (callDuration.asSeconds() === 0) {
if (isAccountMessage) {
return {
text: t('message_call_outgoing_missed'),
Icon: ArrowLeftCurved,
textColor: 'white',
bubbleColor: '#005699' + '80', // opacity 50%
};
} else {
return {
text: t('message_call_incoming_missed'),
Icon: ArrowLeftCurved,
textColor: 'black',
bubbleColor: '#C6C6C6',
};
}
} else {
const minutes = Math.floor(callDuration.asMinutes()).toString().padStart(2, '0');
const seconds = callDuration.format('ss');
const interpolations = {
duration: `${minutes}:${seconds}`,
};
if (isAccountMessage) {
return {
text: t('message_call_outgoing', interpolations),
Icon: ArrowRightUp,
textColor: 'white',
bubbleColor: '#005699',
};
} else {
return {
text: t('message_call_incoming', interpolations),
Icon: ArrowLeftDown,
textcolor: 'black',
bubbleColor: '#E5E5E5',
};
}
}
}, [isAccountMessage, message, t]);
return (
<Bubble position={position} isFirstOfGroup={isFirstOfGroup} isLastOfGroup={isLastOfGroup} bubbleColor={bubbleColor}>
<Stack direction="row" spacing="10px" alignItems="center">
<Icon sx={{ fontSize: '16px', color: textColor }} />
<Typography variant="body1" color={textColor} textAlign={position} fontWeight="bold" textTransform="uppercase">
{text}
</Typography>
</Stack>
</Bubble>
);
};
const MessageInitial = () => {
const { t } = useTranslation();
return <>{t('message_swarm_created')}</>;
};
interface MessageDataTransferProps {
message: Message;
isAccountMessage: boolean;
isFirstOfGroup: boolean;
isLastOfGroup: boolean;
}
const MessageDataTransfer = ({ isAccountMessage, isFirstOfGroup, isLastOfGroup }: MessageDataTransferProps) => {
const position = isAccountMessage ? 'end' : 'start';
return (
<Bubble bubbleColor="#E5E5E5" position={position} isFirstOfGroup={isFirstOfGroup} isLastOfGroup={isLastOfGroup}>
&quot;data-transfer&quot;
</Bubble>
);
};
interface MessageMemberProps {
message: Message;
}
const MessageMember = ({ message }: MessageMemberProps) => {
const { t } = useTranslation();
return (
<Chip
sx={{
width: 'fit-content',
}}
label={t('message_user_joined', { user: message.author })}
/>
);
};
interface MessageTextProps {
message: Message;
isAccountMessage: boolean;
isFirstOfGroup: boolean;
isLastOfGroup: boolean;
}
const MessageText = ({ message, isAccountMessage, isFirstOfGroup, isLastOfGroup }: MessageTextProps) => {
const position = isAccountMessage ? 'end' : 'start';
const bubbleColor = isAccountMessage ? '#005699' : '#E5E5E5';
const textColor = isAccountMessage ? 'white' : 'black';
return (
<MessageTooltip position={position}>
<Bubble
bubbleColor={bubbleColor}
position={position}
isFirstOfGroup={isFirstOfGroup}
isLastOfGroup={isLastOfGroup}
>
<Typography variant="body1" color={textColor} textAlign={position}>
{message.body}
</Typography>
</Bubble>
</MessageTooltip>
);
};
interface DateIndicatorProps {
time: Dayjs;
}
const DateIndicator = ({ time }: DateIndicatorProps) => {
const { i18n } = useTranslation();
const textDate = useMemo(() => {
if (time.isToday()) {
return new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' }).format(0, 'day');
} else if (time.isYesterday()) {
return new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' }).format(-1, 'day');
} else {
return dayjs(time).locale(i18n.language).format('L');
}
}, [i18n, time]);
return (
<Box marginTop="30px">
<Divider
sx={{
'.MuiDivider-wrapper': {
margin: 0,
padding: 0,
},
'&::before': {
borderTop: '1px solid #E5E5E5',
},
'&::after': {
borderTop: '1px solid #E5E5E5',
},
}}
>
<Typography
variant="caption"
fontWeight={700}
border="1px solid #E5E5E5"
borderRadius="5px"
padding="10px 16px"
textTransform="capitalize"
>
{textDate}
</Typography>
</Divider>
</Box>
);
};
interface TimeIndicatorProps {
time: Dayjs;
hasDateOnTop: boolean;
}
const TimeIndicator = ({ time, hasDateOnTop }: TimeIndicatorProps) => {
const { i18n } = useTranslation();
const textTime = useMemo(() => {
return dayjs(time).locale(i18n.language).format('LT');
}, [i18n, time]);
return (
<Stack direction="row" justifyContent="center" marginTop={hasDateOnTop ? '20px' : '30px'}>
<Typography variant="caption" color="#A7A7A7" fontWeight={700}>
{textTime}
</Typography>
</Stack>
);
};
interface NotificationMessageRowProps {
message: Message;
}
const NotificationMessageRow = ({ message }: NotificationMessageRowProps) => {
let messageComponent;
switch (message.type) {
case 'initial':
messageComponent = <MessageInitial />;
break;
case 'member':
messageComponent = <MessageMember message={message} />;
break;
default:
console.error(`${NotificationMessageRow.name} received unhandled message type: ${message.type}`);
return null;
}
return (
<Stack paddingTop={'30px'} alignItems="center">
{messageComponent}
</Stack>
);
};
interface UserMessageRowProps {
message: Message;
isAccountMessage: boolean;
previousMessage: Message | undefined;
nextMessage: Message | undefined;
time: Dayjs;
showsTime: boolean;
author: Account | Contact;
}
const UserMessageRow = ({
message,
previousMessage,
nextMessage,
isAccountMessage,
time,
showsTime,
author,
}: UserMessageRowProps) => {
const authorName = author.getDisplayName();
const position = isAccountMessage ? 'end' : 'start';
const previousIsUserMessageType = checkIsUserMessageType(previousMessage?.type);
const nextIsUserMessageType = checkIsUserMessageType(nextMessage?.type);
const nextTime = dayjs.unix(Number(nextMessage?.timestamp));
const nextShowsTime = checkShowsTime(nextTime, time);
const isFirstOfGroup = showsTime || !previousIsUserMessageType || previousMessage?.author !== message.author;
const isLastOfGroup = nextShowsTime || !nextIsUserMessageType || message.author !== nextMessage?.author;
const props = {
message,
isAccountMessage,
isFirstOfGroup,
isLastOfGroup,
};
let MessageComponent;
switch (message.type) {
case 'text/plain':
MessageComponent = MessageText;
break;
case 'application/data-transfer+json':
MessageComponent = MessageDataTransfer;
break;
case 'application/call-history+json':
MessageComponent = MessageCall;
break;
default:
console.error(`${UserMessageRow.name} received unhandled message type: ${message.type}`);
return null;
}
const participantNamePadding = isAccountMessage
? bubblePadding
: parseInt(avatarSize) + parseInt(spacingBetweenAvatarAndBubble) + parseInt(bubblePadding) + 'px';
return (
<Stack alignItems={position}>
{isFirstOfGroup && (
<Box padding={`30px ${participantNamePadding} 0 ${participantNamePadding}`}>
<ParticipantName name={authorName} />
</Box>
)}
<Stack
direction="row"
justifyContent={position}
alignItems="end"
spacing={spacingBetweenAvatarAndBubble}
paddingTop="6px"
width="66.66%"
>
<Box sx={{ width: avatarSize }}>
{!isAccountMessage && isLastOfGroup && (
<ConversationAvatar
displayName={authorName}
sx={{ width: avatarSize, height: avatarSize, fontSize: '15px' }}
/>
)}
</Box>
<MessageComponent {...props} />
</Stack>
</Stack>
);
};
interface MessageTooltipProps {
className?: string;
position: MessagePosition;
children: ReactElement;
}
const MessageTooltip = styled(({ className, position, children }: MessageTooltipProps) => {
const [open, setOpen] = useState(false);
const emojis = ['😎', '😄', '😍']; // Should be last three used emojis
const additionalOptions = [
{
Icon: TwoSheetsIcon,
text: 'Copy',
action: () => {},
},
{
Icon: OppositeArrowsIcon,
text: 'Transfer',
action: () => {},
},
{
Icon: TrashBinIcon,
text: 'Delete message',
action: () => {},
},
];
const toggleMoreMenu = useCallback(() => setOpen((open) => !open), [setOpen]);
const onClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
return (
<Tooltip
classes={{ tooltip: className }} // Required for styles. Don't know why
placement={position === 'start' ? 'right-start' : 'left-start'}
PopperProps={{
modifiers: [
{
name: 'offset',
options: {
offset: [-2, -30],
},
},
],
}}
onClose={onClose}
title={
<Stack>
{/* Whole tooltip's content */}
<Stack // Main options
direction="row"
spacing="16px"
>
{emojis.map((emoji) => (
<EmojiButton key={emoji} emoji={emoji} />
))}
<ReplyMessageButton />
<MoreButton onClick={toggleMoreMenu} />
</Stack>
{open && ( // Additional menu options
<>
<Divider sx={{ paddingTop: '16px' }} />
<List sx={{ padding: 0, paddingTop: '8px', marginBottom: '-8px' }}>
{additionalOptions.map((option) => (
<ListItemButton
key={option.text}
sx={{
padding: '8px',
}}
>
<Stack // Could not find proper way to set spacing between ListItemIcon and ListItemText
direction="row"
spacing="16px"
>
<option.Icon
sx={{
height: '16px',
margin: 0,
color: (theme: Theme) => theme?.palette?.primary?.dark,
}}
/>
<ListItemText
primary={option.text}
primaryTypographyProps={{
fontSize: '12px',
lineHeight: '16px',
}}
sx={{
height: '16px',
margin: 0,
}}
/>
</Stack>
</ListItemButton>
))}
</List>
</>
)}
</Stack>
}
>
{/* div fixes 'Function components cannot be given refs' error */}
<div>{children}</div>
</Tooltip>
);
})(({ position }) => {
const largeRadius = '20px';
const smallRadius = '5px';
return {
backgroundColor: 'white',
padding: '16px',
boxShadow: '3px 3px 7px #00000029',
borderRadius: largeRadius,
borderStartStartRadius: position === 'start' ? smallRadius : largeRadius,
borderStartEndRadius: position === 'end' ? smallRadius : largeRadius,
};
});
interface BubbleProps {
position: MessagePosition;
isFirstOfGroup: boolean;
isLastOfGroup: boolean;
bubbleColor: string;
children: ReactNode;
}
const Bubble = ({ position, isFirstOfGroup, isLastOfGroup, bubbleColor, children }: BubbleProps) => {
const largeRadius = '20px';
const smallRadius = '5px';
const radius = useMemo(() => {
if (position === 'start') {
return {
borderStartStartRadius: isFirstOfGroup ? largeRadius : smallRadius,
borderStartEndRadius: largeRadius,
borderEndStartRadius: isLastOfGroup ? largeRadius : smallRadius,
borderEndEndRadius: largeRadius,
};
}
return {
borderStartStartRadius: largeRadius,
borderStartEndRadius: isFirstOfGroup ? largeRadius : smallRadius,
borderEndStartRadius: largeRadius,
borderEndEndRadius: isLastOfGroup ? largeRadius : smallRadius,
};
}, [isFirstOfGroup, isLastOfGroup, position]);
return (
<Box
sx={{
width: 'fit-content',
backgroundColor: bubbleColor,
padding: bubblePadding,
...radius,
}}
>
{children}
</Box>
);
};
interface ParticipantNameProps {
name: string;
}
const ParticipantName = ({ name }: ParticipantNameProps) => {
return (
<Typography variant="caption" color="#A7A7A7" fontWeight={700}>
{name}
</Typography>
);
};
interface MessageProps {
messageIndex: number;
messages: Message[];
isAccountMessage: boolean;
author: Account | Contact;
}
export const MessageRow = ({ messageIndex, messages, isAccountMessage, author }: MessageProps) => {
const message = messages[messageIndex];
const previousMessage = findPreviousVisibleMessage(messages, messageIndex);
const nextMessage = findNextVisibleMessage(messages, messageIndex);
const time = dayjs.unix(Number(message.timestamp));
const previousTime = dayjs.unix(Number(previousMessage?.timestamp));
const showDate =
message?.type === 'initial' || previousTime.year() !== time.year() || previousTime.dayOfYear() !== time.dayOfYear();
const showTime = checkShowsTime(time, previousTime);
let messageComponent;
if (checkIsUserMessageType(message.type)) {
messageComponent = (
<UserMessageRow
message={message}
previousMessage={previousMessage}
nextMessage={nextMessage}
time={time}
showsTime={showTime}
isAccountMessage={isAccountMessage}
author={author}
/>
);
} else if (checkIsNotificationMessageType(message.type)) {
messageComponent = <NotificationMessageRow message={message} />;
} else if (checkIsInvisibleMessageType(message.type)) {
return null;
} else {
const _exhaustiveCheck: never = message.type;
return _exhaustiveCheck;
}
return (
<Stack>
{showDate && <DateIndicator time={time} />}
{showTime && <TimeIndicator time={time} hasDateOnTop={showDate} />}
{messageComponent}
</Stack>
);
};