Add link previews

Change-Id: I1792958844c18b4f4a65356dd5a07e3b2b39fcbc
diff --git a/client/package.json b/client/package.json
index 3620108..7a23579 100644
--- a/client/package.json
+++ b/client/package.json
@@ -48,6 +48,8 @@
     "filesize": "^10.0.5",
     "framer-motion": "^7.3.5",
     "i18next": "^21.9.2",
+    "linkify-react": "^4.0.2",
+    "linkifyjs": "^4.0.2",
     "mime": "^3.0.0",
     "qrcode.react": "^3.1.0",
     "react": "^18.2.0",
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&times';
 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: {
diff --git a/common/src/index.ts b/common/src/index.ts
index 0dde015..0af8967 100644
--- a/common/src/index.ts
+++ b/common/src/index.ts
@@ -21,6 +21,7 @@
 export * from './interfaces/auth-interfaces.js';
 export * from './interfaces/contact.js';
 export * from './interfaces/conversation.js';
+export * from './interfaces/link-preview.js';
 export * from './interfaces/lookup-result.js';
 export * from './interfaces/websocket-interfaces.js';
 export * from './interfaces/websocket-message.js';
diff --git a/common/src/interfaces/link-preview.ts b/common/src/interfaces/link-preview.ts
new file mode 100644
index 0000000..51ac198
--- /dev/null
+++ b/common/src/interfaces/link-preview.ts
@@ -0,0 +1,23 @@
+/*
+ * 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/>.
+ */
+
+export type LinkPreview = {
+  title?: string;
+  description?: string;
+  image?: string;
+};
diff --git a/package-lock.json b/package-lock.json
index 1df4dd2..47ef570 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,6 +50,8 @@
         "filesize": "^10.0.5",
         "framer-motion": "^7.3.5",
         "i18next": "^21.9.2",
+        "linkify-react": "^4.0.2",
+        "linkifyjs": "^4.0.2",
         "mime": "^3.0.0",
         "qrcode.react": "^3.1.0",
         "react": "^18.2.0",
@@ -2497,6 +2499,17 @@
       "version": "1.1.1",
       "license": "ISC"
     },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
     "node_modules/accepts": {
       "version": "1.3.8",
       "license": "MIT",
@@ -2906,7 +2919,6 @@
     },
     "node_modules/boolbase": {
       "version": "1.0.0",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/brace-expansion": {
@@ -3190,7 +3202,6 @@
     },
     "node_modules/cheerio-select": {
       "version": "2.1.0",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "boolbase": "^1.0.0",
@@ -3677,6 +3688,14 @@
         "node": ">=10"
       }
     },
+    "node_modules/cross-fetch": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
+      "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
+      "dependencies": {
+        "node-fetch": "2.6.7"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.3",
       "dev": true,
@@ -3692,7 +3711,6 @@
     },
     "node_modules/css-select": {
       "version": "5.1.0",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "boolbase": "^1.0.0",
@@ -3707,7 +3725,6 @@
     },
     "node_modules/css-what": {
       "version": "6.1.0",
-      "dev": true,
       "license": "BSD-2-Clause",
       "engines": {
         "node": ">= 6"
@@ -4020,7 +4037,6 @@
     },
     "node_modules/dom-serializer": {
       "version": "2.0.0",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "domelementtype": "^2.3.0",
@@ -4033,7 +4049,6 @@
     },
     "node_modules/domelementtype": {
       "version": "2.3.0",
-      "dev": true,
       "funding": [
         {
           "type": "github",
@@ -4044,7 +4059,6 @@
     },
     "node_modules/domhandler": {
       "version": "5.0.3",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "domelementtype": "^2.3.0"
@@ -4058,7 +4072,6 @@
     },
     "node_modules/domutils": {
       "version": "3.0.1",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "dom-serializer": "^2.0.0",
@@ -4227,7 +4240,6 @@
     },
     "node_modules/entities": {
       "version": "4.4.0",
-      "dev": true,
       "license": "BSD-2-Clause",
       "engines": {
         "node": ">=0.12"
@@ -4805,6 +4817,14 @@
         "through": "~2.3.1"
       }
     },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/eventemitter2": {
       "version": "6.4.7",
       "dev": true,
@@ -5860,7 +5880,6 @@
     },
     "node_modules/htmlparser2": {
       "version": "8.0.1",
-      "dev": true,
       "funding": [
         "https://github.com/fb55/htmlparser2?sponsor=1",
         {
@@ -7055,6 +7074,57 @@
       "version": "1.2.4",
       "license": "MIT"
     },
+    "node_modules/link-preview-js": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/link-preview-js/-/link-preview-js-3.0.4.tgz",
+      "integrity": "sha512-xsuxMigAZd4xmj6BIwMNuQjjpJdh0DWeIo1NXQgaoWSi9Z/dzz/Kxy6vzzsUonFlMTPJ1i0EC8aeOg/xrOMidg==",
+      "dependencies": {
+        "abort-controller": "^3.0.0",
+        "cheerio": "1.0.0-rc.11",
+        "cross-fetch": "3.1.5",
+        "url": "0.11.0"
+      }
+    },
+    "node_modules/link-preview-js/node_modules/cheerio": {
+      "version": "1.0.0-rc.11",
+      "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.11.tgz",
+      "integrity": "sha512-bQwNaDIBKID5ts/DsdhxrjqFXYfLw4ste+wMKqWA8DyKcS4qwsPP4Bk8ZNaTJjvpiX/qW3BT4sU7d6Bh5i+dag==",
+      "dependencies": {
+        "cheerio-select": "^2.1.0",
+        "dom-serializer": "^2.0.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.0.1",
+        "htmlparser2": "^8.0.1",
+        "parse5": "^7.0.0",
+        "parse5-htmlparser2-tree-adapter": "^7.0.0",
+        "tslib": "^2.4.0"
+      },
+      "engines": {
+        "node": ">= 6"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+      }
+    },
+    "node_modules/link-preview-js/node_modules/tslib": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
+      "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
+    },
+    "node_modules/linkify-react": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.0.2.tgz",
+      "integrity": "sha512-WFHnwOUo6EeKwrQQy1d+UjeKsv+SPQ9toPpaRIXHV1CMo+0kgZBSIsEBxQrFQIEy7WD20QD+sPwNNaJJpynN6g==",
+      "peerDependencies": {
+        "linkifyjs": "^4.0.0",
+        "react": ">= 15.0.0"
+      }
+    },
+    "node_modules/linkifyjs": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.0.2.tgz",
+      "integrity": "sha512-/VSoCZiglX0VMsXmL5PN3lRg45M86lrD9PskdkA2abWaTKap1bIcJ11LS4EE55bcUl9ZOR4eZ792UtQ9E/5xLA=="
+    },
     "node_modules/lint-staged": {
       "version": "13.0.3",
       "dev": true,
@@ -7921,7 +7991,6 @@
     },
     "node_modules/nth-check": {
       "version": "2.1.1",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "boolbase": "^1.0.0"
@@ -8187,7 +8256,6 @@
     },
     "node_modules/parse5": {
       "version": "7.1.1",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "entities": "^4.4.0"
@@ -8198,7 +8266,6 @@
     },
     "node_modules/parse5-htmlparser2-tree-adapter": {
       "version": "7.0.0",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "domhandler": "^5.0.2",
@@ -8528,6 +8595,15 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/querystring": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+      "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
+      "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
+      "engines": {
+        "node": ">=0.4.x"
+      }
+    },
     "node_modules/queue-microtask": {
       "version": "1.2.3",
       "dev": true,
@@ -10090,6 +10166,20 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/url": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
+      "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==",
+      "dependencies": {
+        "punycode": "1.3.2",
+        "querystring": "0.2.0"
+      }
+    },
+    "node_modules/url/node_modules/punycode": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+      "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="
+    },
     "node_modules/use-sync-external-store": {
       "version": "1.2.0",
       "license": "MIT",
@@ -10633,6 +10723,8 @@
         "express-async-handler": "^1.2.0",
         "helmet": "^6.0.0",
         "jose": "^4.10.0",
+        "link-preview-js": "^3.0.4",
+        "linkifyjs": "^4.0.2",
         "loglevel": "^1.8.0",
         "reflect-metadata": "^0.1.13",
         "rxjs": "^7.5.7",
@@ -12154,6 +12246,14 @@
     "abbrev": {
       "version": "1.1.1"
     },
+    "abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "requires": {
+        "event-target-shim": "^5.0.0"
+      }
+    },
     "accepts": {
       "version": "1.3.8",
       "requires": {
@@ -12408,8 +12508,7 @@
       }
     },
     "boolbase": {
-      "version": "1.0.0",
-      "dev": true
+      "version": "1.0.0"
     },
     "brace-expansion": {
       "version": "1.1.11",
@@ -12572,7 +12671,6 @@
     },
     "cheerio-select": {
       "version": "2.1.0",
-      "dev": true,
       "requires": {
         "boolbase": "^1.0.0",
         "css-select": "^5.1.0",
@@ -12881,6 +12979,14 @@
         "yaml": "^1.10.0"
       }
     },
+    "cross-fetch": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
+      "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
+      "requires": {
+        "node-fetch": "2.6.7"
+      }
+    },
     "cross-spawn": {
       "version": "7.0.3",
       "dev": true,
@@ -12892,7 +12998,6 @@
     },
     "css-select": {
       "version": "5.1.0",
-      "dev": true,
       "requires": {
         "boolbase": "^1.0.0",
         "css-what": "^6.1.0",
@@ -12902,8 +13007,7 @@
       }
     },
     "css-what": {
-      "version": "6.1.0",
-      "dev": true
+      "version": "6.1.0"
     },
     "css.escape": {
       "version": "1.5.1"
@@ -13098,7 +13202,6 @@
     },
     "dom-serializer": {
       "version": "2.0.0",
-      "dev": true,
       "requires": {
         "domelementtype": "^2.3.0",
         "domhandler": "^5.0.2",
@@ -13106,19 +13209,16 @@
       }
     },
     "domelementtype": {
-      "version": "2.3.0",
-      "dev": true
+      "version": "2.3.0"
     },
     "domhandler": {
       "version": "5.0.3",
-      "dev": true,
       "requires": {
         "domelementtype": "^2.3.0"
       }
     },
     "domutils": {
       "version": "3.0.1",
-      "dev": true,
       "requires": {
         "dom-serializer": "^2.0.0",
         "domelementtype": "^2.3.0",
@@ -13250,8 +13350,7 @@
       "dev": true
     },
     "entities": {
-      "version": "4.4.0",
-      "dev": true
+      "version": "4.4.0"
     },
     "eol": {
       "version": "0.9.1",
@@ -13625,6 +13724,11 @@
         "through": "~2.3.1"
       }
     },
+    "event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
+    },
     "eventemitter2": {
       "version": "6.4.7",
       "dev": true
@@ -14387,7 +14491,6 @@
     },
     "htmlparser2": {
       "version": "8.0.1",
-      "dev": true,
       "requires": {
         "domelementtype": "^2.3.0",
         "domhandler": "^5.0.2",
@@ -14766,6 +14869,8 @@
         "framer-motion": "^7.3.5",
         "i18next": "^21.9.2",
         "i18next-parser": "^6.5.0",
+        "linkify-react": "^4.0.2",
+        "linkifyjs": "^4.0.2",
         "mime": "^3.0.0",
         "qrcode.react": "^3.1.0",
         "react": "^18.2.0",
@@ -14812,6 +14917,8 @@
         "express-async-handler": "^1.2.0",
         "helmet": "^6.0.0",
         "jose": "^4.10.0",
+        "link-preview-js": "^3.0.4",
+        "linkifyjs": "^4.0.2",
         "loglevel": "^1.8.0",
         "reflect-metadata": "^0.1.13",
         "rxjs": "^7.5.7",
@@ -15175,6 +15282,50 @@
     "lines-and-columns": {
       "version": "1.2.4"
     },
+    "link-preview-js": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/link-preview-js/-/link-preview-js-3.0.4.tgz",
+      "integrity": "sha512-xsuxMigAZd4xmj6BIwMNuQjjpJdh0DWeIo1NXQgaoWSi9Z/dzz/Kxy6vzzsUonFlMTPJ1i0EC8aeOg/xrOMidg==",
+      "requires": {
+        "abort-controller": "^3.0.0",
+        "cheerio": "1.0.0-rc.11",
+        "cross-fetch": "3.1.5",
+        "url": "0.11.0"
+      },
+      "dependencies": {
+        "cheerio": {
+          "version": "1.0.0-rc.11",
+          "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.11.tgz",
+          "integrity": "sha512-bQwNaDIBKID5ts/DsdhxrjqFXYfLw4ste+wMKqWA8DyKcS4qwsPP4Bk8ZNaTJjvpiX/qW3BT4sU7d6Bh5i+dag==",
+          "requires": {
+            "cheerio-select": "^2.1.0",
+            "dom-serializer": "^2.0.0",
+            "domhandler": "^5.0.3",
+            "domutils": "^3.0.1",
+            "htmlparser2": "^8.0.1",
+            "parse5": "^7.0.0",
+            "parse5-htmlparser2-tree-adapter": "^7.0.0",
+            "tslib": "^2.4.0"
+          }
+        },
+        "tslib": {
+          "version": "2.4.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
+          "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
+        }
+      }
+    },
+    "linkify-react": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.0.2.tgz",
+      "integrity": "sha512-WFHnwOUo6EeKwrQQy1d+UjeKsv+SPQ9toPpaRIXHV1CMo+0kgZBSIsEBxQrFQIEy7WD20QD+sPwNNaJJpynN6g==",
+      "requires": {}
+    },
+    "linkifyjs": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.0.2.tgz",
+      "integrity": "sha512-/VSoCZiglX0VMsXmL5PN3lRg45M86lrD9PskdkA2abWaTKap1bIcJ11LS4EE55bcUl9ZOR4eZ792UtQ9E/5xLA=="
+    },
     "lint-staged": {
       "version": "13.0.3",
       "dev": true,
@@ -15678,7 +15829,6 @@
     },
     "nth-check": {
       "version": "2.1.1",
-      "dev": true,
       "requires": {
         "boolbase": "^1.0.0"
       }
@@ -15852,14 +16002,12 @@
     },
     "parse5": {
       "version": "7.1.1",
-      "dev": true,
       "requires": {
         "entities": "^4.4.0"
       }
     },
     "parse5-htmlparser2-tree-adapter": {
       "version": "7.0.0",
-      "dev": true,
       "requires": {
         "domhandler": "^5.0.2",
         "parse5": "^7.0.0"
@@ -16063,6 +16211,11 @@
         "side-channel": "^1.0.4"
       }
     },
+    "querystring": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+      "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g=="
+    },
     "queue-microtask": {
       "version": "1.2.3",
       "dev": true
@@ -17065,6 +17218,22 @@
         "punycode": "^2.1.0"
       }
     },
+    "url": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
+      "integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==",
+      "requires": {
+        "punycode": "1.3.2",
+        "querystring": "0.2.0"
+      },
+      "dependencies": {
+        "punycode": {
+          "version": "1.3.2",
+          "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+          "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="
+        }
+      }
+    },
     "use-sync-external-store": {
       "version": "1.2.0",
       "requires": {}
diff --git a/server/package.json b/server/package.json
index 471079d..85f6d93 100644
--- a/server/package.json
+++ b/server/package.json
@@ -22,6 +22,8 @@
     "express-async-handler": "^1.2.0",
     "helmet": "^6.0.0",
     "jose": "^4.10.0",
+    "link-preview-js": "^3.0.4",
+    "linkifyjs": "^4.0.2",
     "loglevel": "^1.8.0",
     "reflect-metadata": "^0.1.13",
     "rxjs": "^7.5.7",
diff --git a/server/src/app.ts b/server/src/app.ts
index 3e27a88..ff94a59 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -29,6 +29,7 @@
 import { contactsRouter } from './routers/contacts-router.js';
 import { conversationRouter } from './routers/conversation-router.js';
 import { defaultModeratorsRouter } from './routers/default-moderators-router.js';
+import { linkPreviewRouter } from './routers/link-preview-router.js';
 import { nameserverRouter } from './routers/nameserver-router.js';
 import { setupRouter } from './routers/setup-router.js';
 import { bindWebRtcCallbacks } from './websocket/webrtc-handler.js';
@@ -54,6 +55,7 @@
     this.app.use('/default-moderators', defaultModeratorsRouter);
     this.app.use('/conversations', conversationRouter);
     this.app.use('/calls', callRouter);
+    this.app.use('/link-preview', linkPreviewRouter);
     this.app.use('/ns', nameserverRouter);
 
     // Setup WebSocket callbacks
diff --git a/server/src/routers/link-preview-router.ts b/server/src/routers/link-preview-router.ts
new file mode 100644
index 0000000..d7c5d42
--- /dev/null
+++ b/server/src/routers/link-preview-router.ts
@@ -0,0 +1,100 @@
+/*
+ * 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 { Router } from 'express';
+import asyncHandler from 'express-async-handler';
+import { HttpStatusCode } from 'jami-web-common';
+import { getLinkPreview } from 'link-preview-js';
+import * as linkify from 'linkifyjs';
+
+import { authenticateToken } from '../middleware/auth.js';
+
+export const linkPreviewRouter = Router();
+
+// Result of getLinkPreview from link-preview-js
+type LinkPreviewJs = {
+  url: string;
+  title: string;
+  siteName: string | undefined;
+  description: string | undefined;
+  mediaType: string;
+  contentType: string | undefined;
+  images: string[];
+  videos: {
+    url: string | undefined;
+    secureUrl: string | null | undefined;
+    type: string | null | undefined;
+    width: string | undefined;
+    height: string | undefined;
+  }[];
+  favicons: string[];
+};
+
+linkPreviewRouter.use(authenticateToken);
+
+const linkPreviewOptions = {
+  // Allowing redirection from http to https
+  // Code from doc: https://github.com/ospfranco/link-preview-js#redirections
+  followRedirects: 'manual',
+  handleRedirects: (baseURL: string, forwardedURL: string) => {
+    const urlObj = new URL(baseURL);
+    const forwardedURLObj = new URL(forwardedURL);
+    if (
+      forwardedURLObj.hostname === urlObj.hostname ||
+      forwardedURLObj.hostname === 'www.' + urlObj.hostname ||
+      'www.' + forwardedURLObj.hostname === urlObj.hostname
+    ) {
+      return true;
+    } else {
+      return false;
+    }
+  },
+} as const;
+
+linkPreviewRouter.get(
+  '/',
+  asyncHandler(async (req, res) => {
+    const url = req.query.url;
+
+    if (typeof url !== 'string') {
+      res.status(HttpStatusCode.BadRequest).send('Invalid query parameters');
+      return;
+    }
+
+    // Add 'http' or 'https' if absent. This is required by getLinkPreview
+    const sanitizedUrl = linkify.find(url)[0]?.href;
+
+    if (!sanitizedUrl) {
+      res.status(HttpStatusCode.BadRequest).send('Invalid url');
+      return;
+    }
+
+    try {
+      const detailedLinkPreview = (await getLinkPreview(sanitizedUrl, linkPreviewOptions)) as LinkPreviewJs;
+      const linkPreview = {
+        title: detailedLinkPreview.title,
+        description: detailedLinkPreview.description,
+        // We might eventualy want to compare the images in order to select the best fit
+        // https://andrejgajdos.com/how-to-create-a-link-preview/
+        image: detailedLinkPreview.images[0] ?? detailedLinkPreview.favicons[0],
+      };
+      res.json(linkPreview).end();
+    } catch (e) {
+      res.status(HttpStatusCode.NotFound).send('Could not access url');
+    }
+  })
+);