Skip to content

Commit

Permalink
Add URL preview (#1511)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ajbura authored Oct 29, 2023
1 parent a98903a commit 9f9173c
Show file tree
Hide file tree
Showing 11 changed files with 444 additions and 42 deletions.
15 changes: 14 additions & 1 deletion src/app/components/message/layout/Base.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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}
/>
)
);
27 changes: 27 additions & 0 deletions src/app/components/message/layout/layout.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
45 changes: 45 additions & 0 deletions src/app/components/url-preview/UrlPreview.css.tsx
Original file line number Diff line number Diff line change
@@ -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',
},
]);
27 changes: 27 additions & 0 deletions src/app/components/url-preview/UrlPreview.tsx
Original file line number Diff line number Diff line change
@@ -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} />
));
1 change: 1 addition & 0 deletions src/app/components/url-preview/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './UrlPreview';
112 changes: 72 additions & 40 deletions src/app/organisms/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
Time,
MessageBadEncryptedContent,
MessageNotDecryptedContent,
MessageTextBody,
} from '../../components/message';
import {
emojifyAndLinkify,
Expand Down Expand Up @@ -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) => (
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand Down
Loading

0 comments on commit 9f9173c

Please sign in to comment.