Add URL preview (#1511)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sun, 29 Oct 2023 20:14:58 +0000 (07:14 +1100)
committerGitHub <noreply@github.com>
Sun, 29 Oct 2023 20:14:58 +0000 (07:14 +1100)
* URL preview - WIP

* fix url preview regex

* update url match regex

* add url preview components

* add scroll btn url preview holder

* add message body component

* add url preview toggle in settings

* update url regex

* improve url regex

* increase thumbnail size in url preview

* hide url preview in encrypted rooms

* add encrypted room url preview toggle

src/app/components/message/layout/Base.tsx
src/app/components/message/layout/layout.css.ts
src/app/components/url-preview/UrlPreview.css.tsx [new file with mode: 0644]
src/app/components/url-preview/UrlPreview.tsx [new file with mode: 0644]
src/app/components/url-preview/index.ts [new file with mode: 0644]
src/app/organisms/room/RoomTimeline.tsx
src/app/organisms/room/message/UrlPreviewCard.tsx [new file with mode: 0644]
src/app/organisms/room/message/styles.css.ts
src/app/organisms/settings/Settings.jsx
src/app/state/settings.ts
src/app/utils/regex.ts

index 9439ec572ad65e38e982bc9782660a759cb48067..1ce764b516c201920bc43af5e1ddbdc31a963356 100644 (file)
@@ -1,5 +1,5 @@
 import React from 'react';
-import { as } from 'folds';
+import { Text, as } from 'folds';
 import classNames from 'classnames';
 import * as css from './layout.css';
 
@@ -23,3 +23,16 @@ export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
 export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
   <AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
 ));
+
+export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
+  ({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
+    <Text
+      as={asComp}
+      size="T400"
+      priority={notice ? '300' : '400'}
+      className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
+      {...props}
+      ref={ref}
+    />
+  )
+);
index 7b1a267df07618a1afc0f2728c86f13c1107a535..a6b7db0df926ac6f2b268bdc5cd829e24d032715 100644 (file)
@@ -153,3 +153,30 @@ export const Username = style({
     },
   },
 });
+
+export const MessageTextBody = recipe({
+  base: {
+    wordBreak: 'break-word',
+  },
+  variants: {
+    preWrap: {
+      true: {
+        whiteSpace: 'pre-wrap',
+      },
+    },
+    jumboEmoji: {
+      true: {
+        fontSize: '1.504em',
+        lineHeight: '1.4962em',
+      },
+    },
+    emote: {
+      true: {
+        color: color.Success.Main,
+        fontStyle: 'italic',
+      },
+    },
+  },
+});
+
+export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx
new file mode 100644 (file)
index 0000000..3e97c11
--- /dev/null
@@ -0,0 +1,45 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const UrlPreview = style([
+  DefaultReset,
+  {
+    width: toRem(400),
+    minHeight: toRem(102),
+    backgroundColor: color.SurfaceVariant.Container,
+    color: color.SurfaceVariant.OnContainer,
+    border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
+    borderRadius: config.radii.R300,
+    overflow: 'hidden',
+  },
+]);
+
+export const UrlPreviewImg = style([
+  DefaultReset,
+  {
+    width: toRem(100),
+    height: toRem(100),
+    objectFit: 'cover',
+    objectPosition: 'left',
+    backgroundPosition: 'start',
+    flexShrink: 0,
+    overflow: 'hidden',
+  },
+]);
+
+export const UrlPreviewContent = style([
+  DefaultReset,
+  {
+    padding: config.space.S200,
+  },
+]);
+
+export const UrlPreviewDescription = style([
+  DefaultReset,
+  {
+    display: '-webkit-box',
+    WebkitLineClamp: 2,
+    WebkitBoxOrient: 'vertical',
+    overflow: 'hidden',
+  },
+]);
diff --git a/src/app/components/url-preview/UrlPreview.tsx b/src/app/components/url-preview/UrlPreview.tsx
new file mode 100644 (file)
index 0000000..4ba3e4e
--- /dev/null
@@ -0,0 +1,27 @@
+import React from 'react';
+import classNames from 'classnames';
+import { Box, as } from 'folds';
+import * as css from './UrlPreview.css';
+
+export const UrlPreview = as<'div'>(({ className, ...props }, ref) => (
+  <Box shrink="No" className={classNames(css.UrlPreview, className)} {...props} ref={ref} />
+));
+
+export const UrlPreviewImg = as<'img'>(({ className, alt, ...props }, ref) => (
+  <img className={classNames(css.UrlPreviewImg, className)} alt={alt} {...props} ref={ref} />
+));
+
+export const UrlPreviewContent = as<'div'>(({ className, ...props }, ref) => (
+  <Box
+    grow="Yes"
+    direction="Column"
+    gap="100"
+    className={classNames(css.UrlPreviewContent, className)}
+    {...props}
+    ref={ref}
+  />
+));
+
+export const UrlPreviewDescription = as<'span'>(({ className, ...props }, ref) => (
+  <span className={classNames(css.UrlPreviewDescription, className)} {...props} ref={ref} />
+));
diff --git a/src/app/components/url-preview/index.ts b/src/app/components/url-preview/index.ts
new file mode 100644 (file)
index 0000000..6d4dc33
--- /dev/null
@@ -0,0 +1 @@
+export * from './UrlPreview';
index c1b0445834d1393819e2552a2d651555c177dee1..9603209df1b72cee8d41e1ed0d892cf29a0a89df 100644 (file)
@@ -74,6 +74,7 @@ import {
   Time,
   MessageBadEncryptedContent,
   MessageNotDecryptedContent,
+  MessageTextBody,
 } from '../../components/message';
 import {
   emojifyAndLinkify,
@@ -138,13 +139,15 @@ import initMatrix from '../../../client/initMatrix';
 import { useKeyDown } from '../../hooks/useKeyDown';
 import cons from '../../../client/state/cons';
 import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
-import { EMOJI_PATTERN, VARIATION_SELECTOR_PATTERN } from '../../utils/regex';
+import { EMOJI_PATTERN, HTTP_URL_PATTERN, VARIATION_SELECTOR_PATTERN } from '../../utils/regex';
+import { UrlPreviewCard, UrlPreviewHolder } from './message/UrlPreviewCard';
 
 // Thumbs up emoji found to have Variation Selector 16 at the end
 // so included variation selector pattern in regex
 const JUMBO_EMOJI_REG = new RegExp(
   `^(((${EMOJI_PATTERN})|(:.+?:))(${VARIATION_SELECTOR_PATTERN}|\\s)*){1,10}$`
 );
+const URL_REG = new RegExp(HTTP_URL_PATTERN, 'g');
 
 const TimelineFloat = as<'div', css.TimelineFloatVariants>(
   ({ position, className, ...props }, ref) => (
@@ -462,11 +465,15 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
 
 export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
   const mx = useMatrixClient();
+  const encryptedRoom = mx.isRoomEncrypted(room.roomId);
   const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
   const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
   const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
   const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
   const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+  const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
+  const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
   const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
   const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
   const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI();
@@ -1000,22 +1007,27 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
 
       if (typeof body !== 'string') return null;
-      const jumboEmoji = JUMBO_EMOJI_REG.test(trimReplyFromBody(body));
+      const trimmedBody = trimReplyFromBody(body);
+      const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
+      const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
 
       return (
-        <Text
-          as="div"
-          style={{
-            whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap',
-            wordBreak: 'break-word',
-            fontSize: jumboEmoji ? '1.504em' : undefined,
-            lineHeight: jumboEmoji ? '1.4962em' : undefined,
-          }}
-          priority="400"
-        >
-          {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
-          {!!editedEvent && <MessageEditedContent />}
-        </Text>
+        <>
+          <MessageTextBody
+            preWrap={typeof customBody !== 'string'}
+            jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
+          >
+            {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
+            {!!editedEvent && <MessageEditedContent />}
+          </MessageTextBody>
+          {urls && urls.length > 0 && (
+            <UrlPreviewHolder>
+              {urls.map((url) => (
+                <UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
+              ))}
+            </UrlPreviewHolder>
+          )}
+        </>
       );
     },
     renderEmote: (mEventId, mEvent, timelineSet) => {
@@ -1026,21 +1038,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 
       const senderDisplayName =
         getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+
+      if (typeof body !== 'string') return null;
+      const trimmedBody = trimReplyFromBody(body);
+      const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
+      const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
+
       return (
-        <Text
-          as="div"
-          style={{
-            color: color.Success.Main,
-            fontStyle: 'italic',
-            whiteSpace: customBody ? 'initial' : 'pre-wrap',
-            wordBreak: 'break-word',
-          }}
-          priority="400"
-        >
-          <b>{`${senderDisplayName} `}</b>
-          {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
-          {!!editedEvent && <MessageEditedContent />}
-        </Text>
+        <>
+          <MessageTextBody
+            emote
+            preWrap={typeof customBody !== 'string'}
+            jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
+          >
+            <b>{`${senderDisplayName} `}</b>
+            {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
+            {!!editedEvent && <MessageEditedContent />}
+          </MessageTextBody>
+          {urls && urls.length > 0 && (
+            <UrlPreviewHolder>
+              {urls.map((url) => (
+                <UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
+              ))}
+            </UrlPreviewHolder>
+          )}
+        </>
       );
     },
     renderNotice: (mEventId, mEvent, timelineSet) => {
@@ -1049,18 +1071,28 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
 
       if (typeof body !== 'string') return null;
+      const trimmedBody = trimReplyFromBody(body);
+      const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
+      const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
+
       return (
-        <Text
-          as="div"
-          style={{
-            whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap',
-            wordBreak: 'break-word',
-          }}
-          priority="300"
-        >
-          {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
-          {!!editedEvent && <MessageEditedContent />}
-        </Text>
+        <>
+          <MessageTextBody
+            notice
+            preWrap={typeof customBody !== 'string'}
+            jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
+          >
+            {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
+            {!!editedEvent && <MessageEditedContent />}
+          </MessageTextBody>
+          {urls && urls.length > 0 && (
+            <UrlPreviewHolder>
+              {urls.map((url) => (
+                <UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
+              ))}
+            </UrlPreviewHolder>
+          )}
+        </>
       );
     },
     renderImage: (mEventId, mEvent) => {
diff --git a/src/app/organisms/room/message/UrlPreviewCard.tsx b/src/app/organisms/room/message/UrlPreviewCard.tsx
new file mode 100644 (file)
index 0000000..9ae4d29
--- /dev/null
@@ -0,0 +1,183 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { IPreviewUrlResponse } from 'matrix-js-sdk';
+import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import {
+  UrlPreview,
+  UrlPreviewContent,
+  UrlPreviewDescription,
+  UrlPreviewImg,
+} from '../../../components/url-preview';
+import {
+  getIntersectionObserverEntry,
+  useIntersectionObserver,
+} from '../../../hooks/useIntersectionObserver';
+import * as css from './styles.css';
+
+const linkStyles = { color: color.Success.Main };
+
+export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
+  ({ url, ts, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const [previewStatus, loadPreview] = useAsyncCallback(
+      useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
+    );
+    if (previewStatus.status === AsyncStatus.Idle) loadPreview();
+
+    if (previewStatus.status === AsyncStatus.Error) return null;
+
+    const renderContent = (prev: IPreviewUrlResponse) => {
+      const imgUrl = mx.mxcUrlToHttp(prev['og:image'] || '', 256, 256, 'scale', false);
+
+      return (
+        <>
+          {imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
+          <UrlPreviewContent>
+            <Text
+              style={linkStyles}
+              truncate
+              as="a"
+              href={url}
+              target="_blank"
+              rel="no-referrer"
+              size="T200"
+              priority="300"
+            >
+              {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
+              {decodeURIComponent(url)}
+            </Text>
+            <Text truncate priority="400">
+              <b>{prev['og:title']}</b>
+            </Text>
+            <Text size="T200" priority="300">
+              <UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
+            </Text>
+          </UrlPreviewContent>
+        </>
+      );
+    };
+
+    return (
+      <UrlPreview {...props} ref={ref}>
+        {previewStatus.status === AsyncStatus.Success ? (
+          renderContent(previewStatus.data)
+        ) : (
+          <Box grow="Yes" alignItems="Center" justifyContent="Center">
+            <Spinner variant="Secondary" size="400" />
+          </Box>
+        )}
+      </UrlPreview>
+    );
+  }
+);
+
+export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const backAnchorRef = useRef<HTMLDivElement>(null);
+  const frontAnchorRef = useRef<HTMLDivElement>(null);
+  const [backVisible, setBackVisible] = useState(true);
+  const [frontVisible, setFrontVisible] = useState(true);
+
+  const intersectionObserver = useIntersectionObserver(
+    useCallback((entries) => {
+      const backAnchor = backAnchorRef.current;
+      const frontAnchor = frontAnchorRef.current;
+      const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
+      const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
+      if (backEntry) {
+        setBackVisible(backEntry.isIntersecting);
+      }
+      if (frontEntry) {
+        setFrontVisible(frontEntry.isIntersecting);
+      }
+    }, []),
+    useCallback(
+      () => ({
+        root: scrollRef.current,
+        rootMargin: '10px',
+      }),
+      []
+    )
+  );
+
+  useEffect(() => {
+    const backAnchor = backAnchorRef.current;
+    const frontAnchor = frontAnchorRef.current;
+    if (backAnchor) intersectionObserver?.observe(backAnchor);
+    if (frontAnchor) intersectionObserver?.observe(frontAnchor);
+    return () => {
+      if (backAnchor) intersectionObserver?.unobserve(backAnchor);
+      if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
+    };
+  }, [intersectionObserver]);
+
+  const handleScrollBack = () => {
+    const scroll = scrollRef.current;
+    if (!scroll) return;
+    const { offsetWidth, scrollLeft } = scroll;
+    scroll.scrollTo({
+      left: scrollLeft - offsetWidth / 1.3,
+      behavior: 'smooth',
+    });
+  };
+  const handleScrollFront = () => {
+    const scroll = scrollRef.current;
+    if (!scroll) return;
+    const { offsetWidth, scrollLeft } = scroll;
+    scroll.scrollTo({
+      left: scrollLeft + offsetWidth / 1.3,
+      behavior: 'smooth',
+    });
+  };
+
+  return (
+    <Box
+      direction="Column"
+      {...props}
+      ref={ref}
+      style={{ marginTop: config.space.S200, position: 'relative' }}
+    >
+      <Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
+        <Box shrink="No" alignItems="Center">
+          <div ref={backAnchorRef} />
+          {!backVisible && (
+            <>
+              <div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
+              <IconButton
+                className={css.UrlPreviewHolderBtn({ position: 'Left' })}
+                variant="Secondary"
+                radii="Pill"
+                size="300"
+                outlined
+                onClick={handleScrollBack}
+              >
+                <Icon size="300" src={Icons.ArrowLeft} />
+              </IconButton>
+            </>
+          )}
+          <Box alignItems="Inherit" gap="200">
+            {children}
+
+            {!frontVisible && (
+              <>
+                <div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
+                <IconButton
+                  className={css.UrlPreviewHolderBtn({ position: 'Right' })}
+                  variant="Primary"
+                  radii="Pill"
+                  size="300"
+                  outlined
+                  onClick={handleScrollFront}
+                >
+                  <Icon size="300" src={Icons.ArrowRight} />
+                </IconButton>
+              </>
+            )}
+            <div ref={frontAnchorRef} />
+          </Box>
+        </Box>
+      </Scroll>
+    </Box>
+  );
+});
index 801f698d79927d35cf6cff0237ef93bfd26ca39b..d42cf05bfe68dee01d97c9ac87c75edca37ada96 100644 (file)
@@ -1,5 +1,6 @@
 import { style } from '@vanilla-extract/css';
-import { DefaultReset, config, toRem } from 'folds';
+import { recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, color, config, toRem } from 'folds';
 
 export const RelativeBase = style([
   DefaultReset,
@@ -83,3 +84,48 @@ export const ReactionsContainer = style({
 export const ReactionsTooltipText = style({
   wordBreak: 'break-word',
 });
+
+export const UrlPreviewHolderGradient = recipe({
+  base: [
+    DefaultReset,
+    {
+      position: 'absolute',
+      height: '100%',
+      width: toRem(10),
+      zIndex: 1,
+    },
+  ],
+  variants: {
+    position: {
+      Left: {
+        left: 0,
+        background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
+      },
+      Right: {
+        right: 0,
+        background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
+      },
+    },
+  },
+});
+export const UrlPreviewHolderBtn = recipe({
+  base: [
+    DefaultReset,
+    {
+      position: 'absolute',
+      zIndex: 1,
+    },
+  ],
+  variants: {
+    position: {
+      Left: {
+        left: 0,
+        transform: 'translateX(-25%)',
+      },
+      Right: {
+        right: 0,
+        transform: 'translateX(25%)',
+      },
+    },
+  },
+});
index 1b04669cb18d568826b96c2dab37b0909e382d18..47abb45c01fd6796245a687c2c3be0e889999c19 100644 (file)
@@ -59,6 +59,8 @@ function AppearanceSection() {
   const [hideMembershipEvents, setHideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
   const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
   const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+  const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
+  const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
   const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
   const spacings = ['0', '100', '200', '300', '400', '500']
 
@@ -191,6 +193,26 @@ function AppearanceSection() {
           )}
           content={<Text variant="b3">Prevent images and videos from auto loading to save bandwidth.</Text>}
         />
+        <SettingTile
+          title="Url Preview"
+          options={(
+            <Toggle
+              isActive={urlPreview}
+              onToggle={() => setUrlPreview(!urlPreview)}
+            />
+          )}
+          content={<Text variant="b3">Show url preview for link in messages.</Text>}
+        />
+        <SettingTile
+          title="Url Preview in Encrypted Room"
+          options={(
+            <Toggle
+              isActive={encUrlPreview}
+              onToggle={() => setEncUrlPreview(!encUrlPreview)}
+            />
+          )}
+          content={<Text variant="b3">Show url preview for link in encrypted messages.</Text>}
+        />
         <SettingTile
           title="Show hidden events"
           options={(
index 4393b64dbc0e3f9a9fb5dcb695bc5a75be06c413..92d40ff8c671dbb713f1505333b80d48938322ff 100644 (file)
@@ -19,6 +19,8 @@ export interface Settings {
   hideMembershipEvents: boolean;
   hideNickAvatarEvents: boolean;
   mediaAutoLoad: boolean;
+  urlPreview: boolean;
+  encUrlPreview: boolean;
   showHiddenEvents: boolean;
 
   showNotifications: boolean;
@@ -40,6 +42,8 @@ const defaultSettings: Settings = {
   hideMembershipEvents: false,
   hideNickAvatarEvents: true,
   mediaAutoLoad: true,
+  urlPreview: true,
+  encUrlPreview: false,
   showHiddenEvents: false,
 
   showNotifications: true,
index 3b0e981527322daca6438f1f9b16b2bf03ee6422..5188bef0fb6174aec4ea6a9f642592f6885dc86a 100644 (file)
@@ -1,3 +1,5 @@
+export const HTTP_URL_PATTERN = `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(?<![.,:;!/?()[\\]\\s]+)`;
+
 export const URL_NEG_LB = '(?<!(https?|ftp|mailto|magnet):\\/\\/\\S*)';
 
 // https://en.wikipedia.org/wiki/Variation_Selectors_(Unicode_block)