Add link previews
Change-Id: I1792958844c18b4f4a65356dd5a07e3b2b39fcbc
diff --git a/client/src/components/CustomSelect.tsx b/client/src/components/CustomSelect.tsx
index c696eab..ca34d53 100644
--- a/client/src/components/CustomSelect.tsx
+++ b/client/src/components/CustomSelect.tsx
@@ -64,7 +64,7 @@
padding: 0,
paddingTop: '5px',
paddingBottom: '8.5px',
- zIndex: 1,
+ zIndex: 100,
position: 'absolute',
top: '-5px',
listStyle: 'none',
diff --git a/client/src/components/Message.tsx b/client/src/components/Message.tsx
index a87d628..30339c9 100644
--- a/client/src/components/Message.tsx
+++ b/client/src/components/Message.tsx
@@ -15,19 +15,23 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
-import { Box, Chip, Divider, Stack, Tooltip, Typography } from '@mui/material';
+import { Box, Chip, Divider, Link, Stack, Tooltip, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import dayjs, { Dayjs } from 'dayjs';
import { Message } from 'jami-web-common';
+import Linkify from 'linkify-react';
+import * as linkify from 'linkifyjs';
import { ReactElement, ReactNode, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Account } from '../models/account';
import { Contact } from '../models/contact';
+import { useLinkPreviewQuery } from '../services/linkPreviewQueries';
import { getMessageCallText, getMessageMemberText } from '../utils/chatmessages';
import { formatRelativeDate, formatTime } from '../utils/dates×';
import { EmojiButton, MoreButton, ReplyMessageButton } from './Button';
import ConversationAvatar from './ConversationAvatar';
+import LoadingPage from './Loading';
import PopoverList, { PopoverListItemData } from './PopoverList';
import {
ArrowLeftCurved,
@@ -184,6 +188,68 @@
);
};
+type LinkPreviewProps = {
+ isAccountMessage: boolean;
+ link: string;
+};
+
+const LinkPreview = ({ isAccountMessage, link }: LinkPreviewProps) => {
+ const [imageIsWorking, setImageIsWorking] = useState(true);
+ const linkPreviewQuery = useLinkPreviewQuery(link);
+ if (linkPreviewQuery.isLoading) {
+ return <LoadingPage />;
+ }
+ if (!linkPreviewQuery.isSuccess) {
+ return null;
+ }
+ const linkPreview = linkPreviewQuery.data;
+
+ return (
+ <a href={link} style={{ textDecorationLine: 'none' }}>
+ <Stack>
+ {imageIsWorking && linkPreview.image && (
+ <img
+ style={{
+ padding: '15px 0',
+ }}
+ alt={linkPreview.title}
+ src={linkPreview.image}
+ onError={() => setImageIsWorking(false)}
+ />
+ )}
+ <Typography variant="body1" color={isAccountMessage ? '#cccccc' : 'black'}>
+ {linkPreview.title}
+ </Typography>
+ {linkPreview.description && (
+ <Typography variant="body1" sx={{ color: isAccountMessage ? '#ffffff' : '#005699' }}>
+ {linkPreview.description}
+ </Typography>
+ )}
+ <Typography variant="body1" color={isAccountMessage ? '#cccccc' : 'black'}>
+ {new URL(link).hostname}
+ </Typography>
+ </Stack>
+ </a>
+ );
+};
+
+type RenderLinkProps = {
+ attributes: {
+ isAccountMessage: boolean;
+ href: string;
+ };
+ content: ReactNode;
+};
+
+const RenderLink = ({ attributes, content }: RenderLinkProps) => {
+ const { href, isAccountMessage, ...props } = attributes;
+ return (
+ <Link href={href} {...props} variant="body1" color={isAccountMessage ? '#ffffff' : undefined}>
+ {content}
+ </Link>
+ );
+};
+
interface MessageTextProps {
message: Message;
isAccountMessage: boolean;
@@ -195,6 +261,9 @@
const position = isAccountMessage ? 'end' : 'start';
const bubbleColor = isAccountMessage ? '#005699' : '#E5E5E5';
const textColor = isAccountMessage ? 'white' : 'black';
+
+ const link = useMemo(() => linkify.find(message?.body ?? '', 'url')[0]?.href, [message]);
+
return (
<MessageTooltip position={position}>
<Bubble
@@ -202,10 +271,12 @@
position={position}
isFirstOfGroup={isFirstOfGroup}
isLastOfGroup={isLastOfGroup}
+ maxWidth={link ? '400px' : undefined}
>
<Typography variant="body1" color={textColor} textAlign={position}>
- {message.body}
+ <Linkify options={{ render: RenderLink as any, attributes: { isAccountMessage } }}>{message.body}</Linkify>
</Typography>
+ {link && <LinkPreview isAccountMessage={isAccountMessage} link={link} />}
</Bubble>
</MessageTooltip>
);
@@ -471,10 +542,11 @@
isFirstOfGroup: boolean;
isLastOfGroup: boolean;
bubbleColor: string;
+ maxWidth?: string;
children: ReactNode;
}
-const Bubble = ({ position, isFirstOfGroup, isLastOfGroup, bubbleColor, children }: BubbleProps) => {
+const Bubble = ({ position, isFirstOfGroup, isLastOfGroup, bubbleColor, maxWidth, children }: BubbleProps) => {
const largeRadius = '20px';
const smallRadius = '5px';
const radius = useMemo(() => {
@@ -498,8 +570,11 @@
<Box
sx={{
width: 'fit-content',
+ maxWidth,
backgroundColor: bubbleColor,
padding: bubblePadding,
+ overflow: 'hidden',
+ wordWrap: 'break-word',
...radius,
}}
>
diff --git a/client/src/components/Settings.tsx b/client/src/components/Settings.tsx
index ff3ac90..02aef25 100644
--- a/client/src/components/Settings.tsx
+++ b/client/src/components/Settings.tsx
@@ -85,7 +85,7 @@
interface SettingGroupProps {
label: string;
- children: ReactElement[];
+ children: ReactElement[] | ReactElement;
}
export const SettingsGroup = ({ label, children }: SettingGroupProps) => {
diff --git a/client/src/locale/en/translation.json b/client/src/locale/en/translation.json
index 78b607f..cdae799 100644
--- a/client/src/locale/en/translation.json
+++ b/client/src/locale/en/translation.json
@@ -99,8 +99,10 @@
"select_placeholder": "Select an option",
"setting_dark_theme": "Dark theme",
"setting_language": "Language",
+ "setting_link_preview": "Show link previews",
"settings_account": "Account settings",
"settings_general": "General settings",
+ "settings_title_chat": "Chat",
"settings_title_general": "General",
"settings_title_system": "System",
"setup_login_admin_creation": "Let's start by creating a new administrator account to control access to the server configuration.",
diff --git a/client/src/locale/fr/translation.json b/client/src/locale/fr/translation.json
index e030574..d3fdd26 100644
--- a/client/src/locale/fr/translation.json
+++ b/client/src/locale/fr/translation.json
@@ -99,8 +99,10 @@
"select_placeholder": "Sélectionner une option",
"setting_dark_theme": "Thème sombre",
"setting_language": "Langue",
+ "setting_link_preview": "Montrer la prévisualisation des liens",
"settings_account": "Paramètres du compte",
"settings_general": "Paramètres généraux",
+ "settings_title_chat": "Clavardage",
"settings_title_general": "Général",
"settings_title_system": "Système",
"setup_login_admin_creation": "Commençons par créer un nouveau compte administrateur pour contrôler l'accès à la configuration du serveur.",
diff --git a/client/src/pages/GeneralSettings.tsx b/client/src/pages/GeneralSettings.tsx
index 6e651bb..90cfec4 100644
--- a/client/src/pages/GeneralSettings.tsx
+++ b/client/src/pages/GeneralSettings.tsx
@@ -16,7 +16,7 @@
* <https://www.gnu.org/licenses/>.
*/
import { Stack, Typography } from '@mui/material';
-import { useCallback, useContext, useMemo } from 'react';
+import { useCallback, useContext, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SettingSelect, SettingSelectProps, SettingsGroup, SettingSwitch } from '../components/Settings';
@@ -33,6 +33,9 @@
<SettingTheme />
<SettingLanguage />
</SettingsGroup>
+ <SettingsGroup label={t('settings_title_chat')}>
+ <SettingLinkPreview />
+ </SettingsGroup>
</Stack>
);
}
@@ -72,3 +75,11 @@
<SettingSelect label={t('setting_language')} option={option} options={settingLanguageOptions} onChange={onChange} />
);
};
+
+const SettingLinkPreview = () => {
+ const { t } = useTranslation();
+
+ const [isOn, setIsOn] = useState<boolean>(true);
+
+ return <SettingSwitch label={t('setting_link_preview')} onChange={() => setIsOn((isOn) => !isOn)} checked={isOn} />;
+};
diff --git a/client/src/services/linkPreviewQueries.ts b/client/src/services/linkPreviewQueries.ts
new file mode 100644
index 0000000..26d63fe
--- /dev/null
+++ b/client/src/services/linkPreviewQueries.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { useQuery } from '@tanstack/react-query';
+import { LinkPreview } from 'jami-web-common';
+import { useState } from 'react';
+
+import { useAuthContext } from '../contexts/AuthProvider';
+
+export const useLinkPreviewQuery = (url: string) => {
+ const { axiosInstance } = useAuthContext();
+ const [hasFailed, setHasFailed] = useState(false); // Prevent reloading of links that have failed
+ return useQuery({
+ queryKey: ['link-preview', url],
+ queryFn: async () => {
+ const { data } = await axiosInstance.get<LinkPreview>(`/link-preview/?url=${url}`);
+ return data;
+ },
+ onError: () => setHasFailed(true),
+ enabled: !!url && !hasFailed,
+ });
+};
diff --git a/client/src/themes/Default.ts b/client/src/themes/Default.ts
index a29c68d..5d4ddb1 100644
--- a/client/src/themes/Default.ts
+++ b/client/src/themes/Default.ts
@@ -392,6 +392,13 @@
},
},
},
+ MuiLink: {
+ styleOverrides: {
+ root: {
+ color: '#005699',
+ },
+ },
+ },
MuiListItemButton: {
styleOverrides: {
root: {