Compare commits

...

10 commits

Author SHA1 Message Date
77ab3fba1e
style: show entire image preview in room timeline 2023-11-23 13:38:27 +09:00
Krishan
9ecb233763
Release v3.2.0 (#1531)
* Release v3.2.0

* Update cons.js
2023-10-31 21:20:49 +11:00
Ajay Bura
1db0a9eaa8 fix typo in codeblock markdown output 2023-10-31 08:57:59 +05:30
Ajay Bura
687ad8d0f0
Fix blockcode with empty lines not rendered (#1524) 2023-10-31 14:18:30 +11:00
Ajay Bura
c3f564605f
Render reaction with string only key (#1522) 2023-10-31 14:17:57 +11:00
Ajay Bura
c854c7f9d2
Timeline Perf Improvement (#1521)
* emojify msg txt find&replace instead of recursion

* move findAndReplace func in its own file

* improve find and replace

* move markdown file to plugins

* make find and replace work without g flag regex

* fix pagination stop on msg arrive

* render blurhash in small size
2023-10-30 11:28:47 +05:30
Krishan
3713125f57
Fix grammer in membership event messages (#1520) 2023-10-30 11:28:30 +05:30
Ajay Bura
9f9173c691
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
2023-10-30 07:14:58 +11:00
Ajay Bura
a98903a85b
Fix regex to ignore html tag in editor output (#1515) 2023-10-29 22:42:05 +11:00
Ajay Bura
a2cbe79787
Fix broken emoji with md pattern in shortcode (#1514)
* fix broken emoji with md pattern in shortcode

* fix html regex when generating editor output
2023-10-29 21:53:44 +11:00
24 changed files with 552 additions and 104 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "cinny", "name": "cinny",
"version": "3.1.0", "version": "3.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cinny", "name": "cinny",
"version": "3.1.0", "version": "3.2.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",

View file

@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "3.1.0", "version": "3.2.0",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"engines": { "engines": {

View file

@ -3,7 +3,8 @@ import { Descendant, Text } from 'slate';
import { sanitizeText } from '../../utils/sanitize'; import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './types'; import { BlockType } from './types';
import { CustomElement } from './slate'; import { CustomElement } from './slate';
import { parseBlockMD, parseInlineMD, replaceMatch } from '../../utils/markdown'; import { parseBlockMD, parseInlineMD } from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace';
export type OutputOptions = { export type OutputOptions = {
allowTextFormatting?: boolean; allowTextFormatting?: boolean;
@ -51,28 +52,32 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
return `<ul>${children}</ul>`; return `<ul>${children}</ul>`;
case BlockType.Mention: case BlockType.Mention:
return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`; return `<a href="https://matrix.to/#/${encodeURIComponent(node.id)}">${sanitizeText(
node.name
)}</a>`;
case BlockType.Emoticon: case BlockType.Emoticon:
return node.key.startsWith('mxc://') return node.key.startsWith('mxc://')
? `<img data-mx-emoticon src="${node.key}" alt="${node.shortcode}" title="${node.shortcode}" height="32">` ? `<img data-mx-emoticon src="${node.key}" alt="${sanitizeText(
: node.key; node.shortcode
)}" title="${sanitizeText(node.shortcode)}" height="32" />`
: sanitizeText(node.key);
case BlockType.Link: case BlockType.Link:
return `<a href="${node.href}">${node.children}</a>`; return `<a href="${encodeURIComponent(node.href)}">${node.children}</a>`;
case BlockType.Command: case BlockType.Command:
return `/${node.command}`; return `/${sanitizeText(node.command)}`;
default: default:
return children; return children;
} }
}; };
const HTML_TAG_REG = /<([a-z]+)(?![^>]*\/>)[^<]*<\/\1>/; const HTML_TAG_REG_G = /<([\w-]+)(?: [^>]*)?(?:(?:\/>)|(?:>.*?<\/\1>))/g;
const ignoreHTMLParseInlineMD = (text: string): string => { const ignoreHTMLParseInlineMD = (text: string): string =>
if (text === '') return text; findAndReplace(
const match = text.match(HTML_TAG_REG); text,
if (!match) return parseInlineMD(text); HTML_TAG_REG_G,
const [matchedTxt] = match; (match) => match[0],
return replaceMatch((txt) => [ignoreHTMLParseInlineMD(txt)], text, match, matchedTxt).join(''); (txt) => parseInlineMD(txt)
}; ).join('');
export const toMatrixCustomHTML = ( export const toMatrixCustomHTML = (
node: Descendant | Descendant[], node: Descendant | Descendant[],

View file

@ -9,7 +9,7 @@ export const Attachment = recipe({
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
overflow: 'hidden', overflow: 'hidden',
maxWidth: '100%', maxWidth: '100%',
width: toRem(400), // width: toRem(400),
}, },
variants: { variants: {
outlined: { outlined: {
@ -31,7 +31,7 @@ export const AttachmentBox = style([
{ {
maxWidth: '100%', maxWidth: '100%',
maxHeight: toRem(600), maxHeight: toRem(600),
width: toRem(400), // width: toRem(400),
overflow: 'hidden', overflow: 'hidden',
}, },
]); ]);

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { as } from 'folds'; import { Text, as } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import * as css from './layout.css'; 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 Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
<AsUsername className={classNames(css.Username, className)} {...props} ref={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

@ -90,13 +90,13 @@ export const useMemberEventParser = (): MemberEventParser => {
senderId === userId ? ( senderId === userId ? (
<> <>
<b>{userName}</b> <b>{userName}</b>
{' reject the invitation '} {' rejected the invitation '}
{content.reason} {content.reason}
</> </>
) : ( ) : (
<> <>
<b>{senderName}</b> <b>{senderName}</b>
{' reject '} {' rejected '}
<b>{userName}</b> <b>{userName}</b>
{`'s join request `} {`'s join request `}
{content.reason} {content.reason}

View file

@ -74,6 +74,7 @@ import {
Time, Time,
MessageBadEncryptedContent, MessageBadEncryptedContent,
MessageNotDecryptedContent, MessageNotDecryptedContent,
MessageTextBody,
} from '../../components/message'; } from '../../components/message';
import { import {
emojifyAndLinkify, emojifyAndLinkify,
@ -138,13 +139,15 @@ import initMatrix from '../../../client/initMatrix';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; 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 // Thumbs up emoji found to have Variation Selector 16 at the end
// so included variation selector pattern in regex // so included variation selector pattern in regex
const JUMBO_EMOJI_REG = new RegExp( const JUMBO_EMOJI_REG = new RegExp(
`^(((${EMOJI_PATTERN})|(:.+?:))(${VARIATION_SELECTOR_PATTERN}|\\s)*){1,10}$` `^(((${EMOJI_PATTERN})|(:.+?:))(${VARIATION_SELECTOR_PATTERN}|\\s)*){1,10}$`
); );
const URL_REG = new RegExp(HTTP_URL_PATTERN, 'g');
const TimelineFloat = as<'div', css.TimelineFloatVariants>( const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => ( ({ position, className, ...props }, ref) => (
@ -342,7 +345,6 @@ const useTimelinePagination = (
return async (backwards: boolean) => { return async (backwards: boolean) => {
if (fetching) return; if (fetching) return;
const targetTimeline = timelineRef.current;
const { linkedTimelines: lTimelines } = timelineRef.current; const { linkedTimelines: lTimelines } = timelineRef.current;
const timelinesEventsCount = lTimelines.map(timelineToEventsCount); const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
@ -382,7 +384,6 @@ const useTimelinePagination = (
} }
fetching = false; fetching = false;
if (targetTimeline !== timelineRef.current) return;
if (alive()) { if (alive()) {
recalibratePagination(lTimelines, timelinesEventsCount, backwards); recalibratePagination(lTimelines, timelinesEventsCount, backwards);
} }
@ -462,11 +463,15 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) { export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const encryptedRoom = mx.isRoomEncrypted(room.roomId);
const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); 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 [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI(); const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI();
@ -1000,22 +1005,27 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
if (typeof body !== 'string') return null; 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 ( return (
<Text <>
as="div" <MessageTextBody
style={{ preWrap={typeof customBody !== 'string'}
whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap', jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
wordBreak: 'break-word',
fontSize: jumboEmoji ? '1.504em' : undefined,
lineHeight: jumboEmoji ? '1.4962em' : undefined,
}}
priority="400"
> >
{renderBody(body, typeof customBody === 'string' ? customBody : undefined)} {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
{!!editedEvent && <MessageEditedContent />} {!!editedEvent && <MessageEditedContent />}
</Text> </MessageTextBody>
{urls && urls.length > 0 && (
<UrlPreviewHolder>
{urls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
))}
</UrlPreviewHolder>
)}
</>
); );
}, },
renderEmote: (mEventId, mEvent, timelineSet) => { renderEmote: (mEventId, mEvent, timelineSet) => {
@ -1026,21 +1036,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderDisplayName = const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; 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 ( return (
<Text <>
as="div" <MessageTextBody
style={{ emote
color: color.Success.Main, preWrap={typeof customBody !== 'string'}
fontStyle: 'italic', jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
whiteSpace: customBody ? 'initial' : 'pre-wrap',
wordBreak: 'break-word',
}}
priority="400"
> >
<b>{`${senderDisplayName} `}</b> <b>{`${senderDisplayName} `}</b>
{renderBody(body, typeof customBody === 'string' ? customBody : undefined)} {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
{!!editedEvent && <MessageEditedContent />} {!!editedEvent && <MessageEditedContent />}
</Text> </MessageTextBody>
{urls && urls.length > 0 && (
<UrlPreviewHolder>
{urls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
))}
</UrlPreviewHolder>
)}
</>
); );
}, },
renderNotice: (mEventId, mEvent, timelineSet) => { renderNotice: (mEventId, mEvent, timelineSet) => {
@ -1049,18 +1069,28 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
if (typeof body !== 'string') return null; 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 ( return (
<Text <>
as="div" <MessageTextBody
style={{ notice
whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap', preWrap={typeof customBody !== 'string'}
wordBreak: 'break-word', jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
}}
priority="300"
> >
{renderBody(body, typeof customBody === 'string' ? customBody : undefined)} {renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
{!!editedEvent && <MessageEditedContent />} {!!editedEvent && <MessageEditedContent />}
</Text> </MessageTextBody>
{urls && urls.length > 0 && (
<UrlPreviewHolder>
{urls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
))}
</UrlPreviewHolder>
)}
</>
); );
}, },
renderImage: (mEventId, mEvent) => { renderImage: (mEventId, mEvent) => {
@ -1070,13 +1100,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (typeof mxcUrl !== 'string') { if (typeof mxcUrl !== 'string') {
return null; return null;
} }
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400); // const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
return ( return (
<Attachment> <Attachment>
<AttachmentBox <AttachmentBox
style={{ style={{
height: toRem(height < 48 ? 48 : height), // height: toRem(height < 48 ? 48 : height),
}} }}
> >
<ImageContent <ImageContent

View file

@ -98,7 +98,13 @@ export const ImageContent = as<'div', ImageContentProps>(
</Overlay> </Overlay>
)} )}
{typeof blurHash === 'string' && !load && ( {typeof blurHash === 'string' && !load && (
<BlurhashCanvas style={{ width: '100%', height: '100%' }} hash={blurHash} punch={1} /> <BlurhashCanvas
style={{ width: '100%', height: '100%' }}
width={32}
height={32}
hash={blurHash}
punch={1}
/>
)} )}
{!autoPlay && srcState.status === AsyncStatus.Idle && ( {!autoPlay && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
@ -115,7 +121,7 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box> </Box>
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={css.AbsoluteContainer}> <Box className={css.AbsoluteContainer} style={{position: 'unset'}}>
<Image <Image
alt={body} alt={body}
title={body} title={body}

View file

@ -43,7 +43,6 @@ export const Reactions = as<'div', ReactionsProps>(
evt.stopPropagation(); evt.stopPropagation();
evt.preventDefault(); evt.preventDefault();
const key = evt.currentTarget.getAttribute('data-reaction-key'); const key = evt.currentTarget.getAttribute('data-reaction-key');
console.log(key);
if (!key) setViewer(true); if (!key) setViewer(true);
else setViewer(key); else setViewer(key);
}; };
@ -58,7 +57,7 @@ export const Reactions = as<'div', ReactionsProps>(
> >
{reactions.map(([key, events]) => { {reactions.map(([key, events]) => {
const rEvents = Array.from(events); const rEvents = Array.from(events);
if (rEvents.length === 0) return null; if (rEvents.length === 0 || typeof key !== 'string') return null;
const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined; const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
const isPressed = !!myREvent?.getRelation(); const isPressed = !!myREvent?.getRelation();

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

@ -88,7 +88,13 @@ export const VideoContent = as<'div', VideoContentProps>(
return ( return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}> <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
{typeof blurHash === 'string' && !load && ( {typeof blurHash === 'string' && !load && (
<BlurhashCanvas style={{ width: '100%', height: '100%' }} hash={blurHash} punch={1} /> <BlurhashCanvas
style={{ width: '100%', height: '100%' }}
width={32}
height={32}
hash={blurHash}
punch={1}
/>
)} )}
{thumbSrcState.status === AsyncStatus.Success && !load && ( {thumbSrcState.status === AsyncStatus.Success && !load && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">

View file

@ -1,5 +1,6 @@
import { style } from '@vanilla-extract/css'; 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([ export const RelativeBase = style([
DefaultReset, DefaultReset,
@ -83,3 +84,48 @@ export const ReactionsContainer = style({
export const ReactionsTooltipText = style({ export const ReactionsTooltipText = style({
wordBreak: 'break-word', 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

@ -41,7 +41,12 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
relations, relations,
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []) useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
); );
const [selectedKey, setSelectedKey] = useState<string>(initialKey ?? reactions[0][0]);
const [selectedKey, setSelectedKey] = useState<string>(() => {
if (initialKey) return initialKey;
const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string');
return defaultReaction ? defaultReaction[0] : '';
});
const getName = (member: RoomMember) => const getName = (member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
@ -68,7 +73,9 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
<Box shrink="No" className={css.Sidebar}> <Box shrink="No" className={css.Sidebar}>
<Scroll visibility="Hover" hideTrack size="300"> <Scroll visibility="Hover" hideTrack size="300">
<Box className={css.SidebarContent} direction="Column" gap="200"> <Box className={css.SidebarContent} direction="Column" gap="200">
{reactions.map(([key, evts]) => ( {reactions.map(([key, evts]) => {
if (typeof key !== 'string') return null;
return (
<Reaction <Reaction
key={key} key={key}
mx={mx} mx={mx}
@ -77,7 +84,8 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
aria-selected={key === selectedKey} aria-selected={key === selectedKey}
onClick={() => setSelectedKey(key)} onClick={() => setSelectedKey(key)}
/> />
))} );
})}
</Box> </Box>
</Scroll> </Scroll>
</Box> </Box>

View file

@ -59,6 +59,8 @@ function AppearanceSection() {
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideMembershipEvents, setHideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); 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 [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const spacings = ['0', '100', '200', '300', '400', '500'] 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>} 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 <SettingTile
title="Show hidden events" title="Show hidden events"
options={( options={(

View file

@ -248,13 +248,13 @@ const HeadingRule: BlockMDRule = {
}; };
const CODEBLOCK_MD_1 = '```'; const CODEBLOCK_MD_1 = '```';
const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((.+\n)+)`{3} *(?!.)\n?/m; const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m;
const CodeBlockRule: BlockMDRule = { const CodeBlockRule: BlockMDRule = {
match: (text) => text.match(CODEBLOCK_REG_1), match: (text) => text.match(CODEBLOCK_REG_1),
html: (match) => { html: (match) => {
const [, g1, g2] = match; const [, g1, g2] = match;
const classNameAtt = g1 ? ` class="language-${g1}"` : ''; const classNameAtt = g1 ? ` class="language-${g1}"` : '';
return `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}>${g2}</code$></pre>`; return `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}>${g2}</code></pre>`;
}, },
}; };
@ -285,7 +285,7 @@ const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */;
const O_LIST_START = /^([\d])\./; const O_LIST_START = /^([\d])\./;
const O_LIST_TYPE = /^([aAiI])\./; const O_LIST_TYPE = /^([aAiI])\./;
const O_LIST_TRAILING_NEWLINE = /\n$/; const O_LIST_TRAILING_NEWLINE = /\n$/;
const ORDERED_LIST_REG_1 = /(^(-|[\da-zA-Z]\.) +.+\n?)+/m; const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m;
const OrderedListRule: BlockMDRule = { const OrderedListRule: BlockMDRule = {
match: (text) => text.match(ORDERED_LIST_REG_1), match: (text) => text.match(ORDERED_LIST_REG_1),
html: (match, parseInline) => { html: (match, parseInline) => {

View file

@ -18,11 +18,11 @@ import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
import { getMemberDisplayName } from '../utils/room'; import { getMemberDisplayName } from '../utils/room';
import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex'; import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex';
import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
import { replaceMatch } from '../utils/markdown'; import { findAndReplace } from '../utils/findAndReplace';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
const EMOJI_REG = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`); const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
export const LINKIFY_OPTS: LinkifyOpts = { export const LINKIFY_OPTS: LinkifyOpts = {
attributes: { attributes: {
@ -35,26 +35,22 @@ export const LINKIFY_OPTS: LinkifyOpts = {
ignoreTags: ['span'], ignoreTags: ['span'],
}; };
const stringToEmojifyJSX = (text: string): (string | JSX.Element)[] => { const textToEmojifyJSX = (text: string): (string | JSX.Element)[] =>
const match = text.match(EMOJI_REG); findAndReplace(
if (!match) return [text];
const [emoji] = match;
return replaceMatch(
stringToEmojifyJSX,
text, text,
match, EMOJI_REG_G,
<span className={css.EmoticonBase}> (match, pushIndex) => (
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(emoji))}> <span key={pushIndex} className={css.EmoticonBase}>
{emoji} <span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
{match[0]}
</span> </span>
</span> </span>
),
(txt) => txt
); );
};
export const emojifyAndLinkify = (text: string, linkify?: boolean) => { export const emojifyAndLinkify = (text: string, linkify?: boolean) => {
const emojifyJSX = stringToEmojifyJSX(text); const emojifyJSX = textToEmojifyJSX(text);
if (linkify) { if (linkify) {
return <Linkify options={LINKIFY_OPTS}>{emojifyJSX}</Linkify>; return <Linkify options={LINKIFY_OPTS}>{emojifyJSX}</Linkify>;

View file

@ -19,6 +19,8 @@ export interface Settings {
hideMembershipEvents: boolean; hideMembershipEvents: boolean;
hideNickAvatarEvents: boolean; hideNickAvatarEvents: boolean;
mediaAutoLoad: boolean; mediaAutoLoad: boolean;
urlPreview: boolean;
encUrlPreview: boolean;
showHiddenEvents: boolean; showHiddenEvents: boolean;
showNotifications: boolean; showNotifications: boolean;
@ -40,6 +42,8 @@ const defaultSettings: Settings = {
hideMembershipEvents: false, hideMembershipEvents: false,
hideNickAvatarEvents: true, hideNickAvatarEvents: true,
mediaAutoLoad: true, mediaAutoLoad: true,
urlPreview: true,
encUrlPreview: false,
showHiddenEvents: false, showHiddenEvents: false,
showNotifications: true, showNotifications: true,

View file

@ -0,0 +1,28 @@
export type ReplaceCallback<R> = (
match: RegExpExecArray | RegExpMatchArray,
pushIndex: number
) => R;
export type ConvertPartCallback<R> = (text: string, pushIndex: number) => R;
export const findAndReplace = <ReplaceReturnType, ConvertReturnType>(
text: string,
regex: RegExp,
replace: ReplaceCallback<ReplaceReturnType>,
convertPart: ConvertPartCallback<ConvertReturnType>
): Array<ReplaceReturnType | ConvertReturnType> => {
const result: Array<ReplaceReturnType | ConvertReturnType> = [];
let lastEnd = 0;
let match: RegExpExecArray | RegExpMatchArray | null = regex.exec(text);
while (match !== null && typeof match.index === 'number') {
result.push(convertPart(text.slice(lastEnd, match.index), result.length));
result.push(replace(match, result.length));
lastEnd = match.index + match[0].length;
if (regex.global) match = regex.exec(text);
}
result.push(convertPart(text.slice(lastEnd), result.length));
return result;
};

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*)'; export const URL_NEG_LB = '(?<!(https?|ftp|mailto|magnet):\\/\\/\\S*)';
// https://en.wikipedia.org/wiki/Variation_Selectors_(Unicode_block) // https://en.wikipedia.org/wiki/Variation_Selectors_(Unicode_block)

View file

@ -1,5 +1,5 @@
const cons = { const cons = {
version: '3.1.0', version: '3.2.0',
secretKey: { secretKey: {
ACCESS_TOKEN: 'cinny_access_token', ACCESS_TOKEN: 'cinny_access_token',
DEVICE_ID: 'cinny_device_id', DEVICE_ID: 'cinny_device_id',