import React from 'react';
-import { as } from 'folds';
+import { Text, as } from 'folds';
import classNames from 'classnames';
import * as css from './layout.css';
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}
+ />
+ )
+);
},
},
});
+
+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>;
--- /dev/null
+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',
+ },
+]);
--- /dev/null
+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} />
+));
--- /dev/null
+export * from './UrlPreview';
Time,
MessageBadEncryptedContent,
MessageNotDecryptedContent,
+ MessageTextBody,
} from '../../components/message';
import {
emojifyAndLinkify,
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) => (
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();
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) => {
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) => {
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) => {
--- /dev/null
+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>
+ );
+});
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,
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%)',
+ },
+ },
+ },
+});
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']
)}
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={(
hideMembershipEvents: boolean;
hideNickAvatarEvents: boolean;
mediaAutoLoad: boolean;
+ urlPreview: boolean;
+ encUrlPreview: boolean;
showHiddenEvents: boolean;
showNotifications: boolean;
hideMembershipEvents: false,
hideNickAvatarEvents: true,
mediaAutoLoad: true,
+ urlPreview: true,
+ encUrlPreview: false,
showHiddenEvents: false,
showNotifications: true,
+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)