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) => ( + {alt} +)); + +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.} + />