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:
parent
a98903a85b
commit
9f9173c691
11 changed files with 444 additions and 42 deletions
|
@ -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}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -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
src/app/components/url-preview/UrlPreview.css.tsx
Normal file
45
src/app/components/url-preview/UrlPreview.css.tsx
Normal 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',
|
||||
},
|
||||
]);
|
27
src/app/components/url-preview/UrlPreview.tsx
Normal file
27
src/app/components/url-preview/UrlPreview.tsx
Normal 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} />
|
||||
));
|
1
src/app/components/url-preview/index.ts
Normal file
1
src/app/components/url-preview/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './UrlPreview';
|
|
@ -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) => {
|
||||
|
|
183
src/app/organisms/room/message/UrlPreviewCard.tsx
Normal file
183
src/app/organisms/room/message/UrlPreviewCard.tsx
Normal 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>
|
||||
);
|
||||
});
|
|
@ -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%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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={(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue