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
This commit is contained in:
Ajay Bura 2023-10-30 07:14:58 +11:00 committed by GitHub
parent a98903a85b
commit 9f9173c691
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 444 additions and 42 deletions

View 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}
/>
)
);

View 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>;

View file

@ -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',
},
]);

View file

@ -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} />
));

View file

@ -0,0 +1 @@
export * from './UrlPreview';

View 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) => {

View file

@ -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>
);
});

View 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%)',
},
},
},
});

View 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={(

View 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,

View 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)