From 9f9173c691aa61d3dff87aa2d26e4231452ab755 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 30 Oct 2023 07:14:58 +1100
Subject: [PATCH] Add URL preview (#1511)
* 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 | 15 +-
.../components/message/layout/layout.css.ts | 27 +++
.../components/url-preview/UrlPreview.css.tsx | 45 +++++
src/app/components/url-preview/UrlPreview.tsx | 27 +++
src/app/components/url-preview/index.ts | 1 +
src/app/organisms/room/RoomTimeline.tsx | 112 +++++++----
.../organisms/room/message/UrlPreviewCard.tsx | 183 ++++++++++++++++++
src/app/organisms/room/message/styles.css.ts | 48 ++++-
src/app/organisms/settings/Settings.jsx | 22 +++
src/app/state/settings.ts | 4 +
src/app/utils/regex.ts | 2 +
11 files changed, 444 insertions(+), 42 deletions(-)
create mode 100644 src/app/components/url-preview/UrlPreview.css.tsx
create mode 100644 src/app/components/url-preview/UrlPreview.tsx
create mode 100644 src/app/components/url-preview/index.ts
create mode 100644 src/app/organisms/room/message/UrlPreviewCard.tsx
diff --git a/src/app/components/message/layout/Base.tsx b/src/app/components/message/layout/Base.tsx
index 9439ec5..1ce764b 100644
--- a/src/app/components/message/layout/Base.tsx
+++ b/src/app/components/message/layout/Base.tsx
@@ -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) => (
));
+
+export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
+ ({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
+
+ )
+);
diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts
index 7b1a267..a6b7db0 100644
--- a/src/app/components/message/layout/layout.css.ts
+++ b/src/app/components/message/layout/layout.css.ts
@@ -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;
diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx
new file mode 100644
index 0000000..3e97c11
--- /dev/null
+++ b/src/app/components/url-preview/UrlPreview.css.tsx
@@ -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
index 0000000..4ba3e4e
--- /dev/null
+++ b/src/app/components/url-preview/UrlPreview.tsx
@@ -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) => (
+
+));
+
+export const UrlPreviewImg = as<'img'>(({ className, alt, ...props }, ref) => (
+
+));
+
+export const UrlPreviewContent = as<'div'>(({ className, ...props }, ref) => (
+
+));
+
+export const UrlPreviewDescription = as<'span'>(({ className, ...props }, ref) => (
+
+));
diff --git a/src/app/components/url-preview/index.ts b/src/app/components/url-preview/index.ts
new file mode 100644
index 0000000..6d4dc33
--- /dev/null
+++ b/src/app/components/url-preview/index.ts
@@ -0,0 +1 @@
+export * from './UrlPreview';
diff --git a/src/app/organisms/room/RoomTimeline.tsx b/src/app/organisms/room/RoomTimeline.tsx
index c1b0445..9603209 100644
--- a/src/app/organisms/room/RoomTimeline.tsx
+++ b/src/app/organisms/room/RoomTimeline.tsx
@@ -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 (
-
- {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
- {!!editedEvent && }
-
+ <>
+
+ {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
+ {!!editedEvent && }
+
+ {urls && urls.length > 0 && (
+
+ {urls.map((url) => (
+
+ ))}
+
+ )}
+ >
);
},
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 (
-
- {`${senderDisplayName} `}
- {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
- {!!editedEvent && }
-
+ <>
+
+ {`${senderDisplayName} `}
+ {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
+ {!!editedEvent && }
+
+ {urls && urls.length > 0 && (
+
+ {urls.map((url) => (
+
+ ))}
+
+ )}
+ >
);
},
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 (
-
- {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
- {!!editedEvent && }
-
+ <>
+
+ {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
+ {!!editedEvent && }
+
+ {urls && urls.length > 0 && (
+
+ {urls.map((url) => (
+
+ ))}
+
+ )}
+ >
);
},
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
index 0000000..9ae4d29
--- /dev/null
+++ b/src/app/organisms/room/message/UrlPreviewCard.tsx
@@ -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 && }
+
+
+ {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
+ {decodeURIComponent(url)}
+
+
+ {prev['og:title']}
+
+
+ {prev['og:description']}
+
+
+ >
+ );
+ };
+
+ return (
+
+ {previewStatus.status === AsyncStatus.Success ? (
+ renderContent(previewStatus.data)
+ ) : (
+
+
+
+ )}
+
+ );
+ }
+);
+
+export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
+ const scrollRef = useRef(null);
+ const backAnchorRef = useRef(null);
+ const frontAnchorRef = useRef(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 (
+
+
+
+
+ {!backVisible && (
+ <>
+
+
+
+
+ >
+ )}
+
+ {children}
+
+ {!frontVisible && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+
+ );
+});
diff --git a/src/app/organisms/room/message/styles.css.ts b/src/app/organisms/room/message/styles.css.ts
index 801f698..d42cf05 100644
--- a/src/app/organisms/room/message/styles.css.ts
+++ b/src/app/organisms/room/message/styles.css.ts
@@ -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%)',
+ },
+ },
+ },
+});
diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx
index 1b04669..47abb45 100644
--- a/src/app/organisms/settings/Settings.jsx
+++ b/src/app/organisms/settings/Settings.jsx
@@ -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={Prevent images and videos from auto loading to save bandwidth.}
/>
+ setUrlPreview(!urlPreview)}
+ />
+ )}
+ content={Show url preview for link in messages.}
+ />
+ setEncUrlPreview(!encUrlPreview)}
+ />
+ )}
+ content={Show url preview for link in encrypted messages.}
+ />