Compare commits
10 commits
3cef074c9e
...
77ab3fba1e
Author | SHA1 | Date | |
---|---|---|---|
77ab3fba1e | |||
|
9ecb233763 | ||
|
1db0a9eaa8 | ||
|
687ad8d0f0 | ||
|
c3f564605f | ||
|
c854c7f9d2 | ||
|
3713125f57 | ||
|
9f9173c691 | ||
|
a98903a85b | ||
|
a2cbe79787 |
24 changed files with 552 additions and 104 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "cinny",
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cinny",
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "4.5.14",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cinny",
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
|
|
|
@ -3,7 +3,8 @@ import { Descendant, Text } from 'slate';
|
|||
import { sanitizeText } from '../../utils/sanitize';
|
||||
import { BlockType } from './types';
|
||||
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 = {
|
||||
allowTextFormatting?: boolean;
|
||||
|
@ -51,28 +52,32 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
|
|||
return `<ul>${children}</ul>`;
|
||||
|
||||
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:
|
||||
return node.key.startsWith('mxc://')
|
||||
? `<img data-mx-emoticon src="${node.key}" alt="${node.shortcode}" title="${node.shortcode}" height="32">`
|
||||
: node.key;
|
||||
? `<img data-mx-emoticon src="${node.key}" alt="${sanitizeText(
|
||||
node.shortcode
|
||||
)}" title="${sanitizeText(node.shortcode)}" height="32" />`
|
||||
: sanitizeText(node.key);
|
||||
case BlockType.Link:
|
||||
return `<a href="${node.href}">${node.children}</a>`;
|
||||
return `<a href="${encodeURIComponent(node.href)}">${node.children}</a>`;
|
||||
case BlockType.Command:
|
||||
return `/${node.command}`;
|
||||
return `/${sanitizeText(node.command)}`;
|
||||
default:
|
||||
return children;
|
||||
}
|
||||
};
|
||||
|
||||
const HTML_TAG_REG = /<([a-z]+)(?![^>]*\/>)[^<]*<\/\1>/;
|
||||
const ignoreHTMLParseInlineMD = (text: string): string => {
|
||||
if (text === '') return text;
|
||||
const match = text.match(HTML_TAG_REG);
|
||||
if (!match) return parseInlineMD(text);
|
||||
const [matchedTxt] = match;
|
||||
return replaceMatch((txt) => [ignoreHTMLParseInlineMD(txt)], text, match, matchedTxt).join('');
|
||||
};
|
||||
const HTML_TAG_REG_G = /<([\w-]+)(?: [^>]*)?(?:(?:\/>)|(?:>.*?<\/\1>))/g;
|
||||
const ignoreHTMLParseInlineMD = (text: string): string =>
|
||||
findAndReplace(
|
||||
text,
|
||||
HTML_TAG_REG_G,
|
||||
(match) => match[0],
|
||||
(txt) => parseInlineMD(txt)
|
||||
).join('');
|
||||
|
||||
export const toMatrixCustomHTML = (
|
||||
node: Descendant | Descendant[],
|
||||
|
|
|
@ -9,7 +9,7 @@ export const Attachment = recipe({
|
|||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
maxWidth: '100%',
|
||||
width: toRem(400),
|
||||
// width: toRem(400),
|
||||
},
|
||||
variants: {
|
||||
outlined: {
|
||||
|
@ -31,7 +31,7 @@ export const AttachmentBox = style([
|
|||
{
|
||||
maxWidth: '100%',
|
||||
maxHeight: toRem(600),
|
||||
width: toRem(400),
|
||||
// width: toRem(400),
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -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';
|
|
@ -90,13 +90,13 @@ export const useMemberEventParser = (): MemberEventParser => {
|
|||
senderId === userId ? (
|
||||
<>
|
||||
<b>{userName}</b>
|
||||
{' reject the invitation '}
|
||||
{' rejected the invitation '}
|
||||
{content.reason}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>{senderName}</b>
|
||||
{' reject '}
|
||||
{' rejected '}
|
||||
<b>{userName}</b>
|
||||
{`'s join request `}
|
||||
{content.reason}
|
||||
|
|
|
@ -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) => (
|
||||
|
@ -342,7 +345,6 @@ const useTimelinePagination = (
|
|||
|
||||
return async (backwards: boolean) => {
|
||||
if (fetching) return;
|
||||
const targetTimeline = timelineRef.current;
|
||||
const { linkedTimelines: lTimelines } = timelineRef.current;
|
||||
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
|
||||
|
||||
|
@ -382,7 +384,6 @@ const useTimelinePagination = (
|
|||
}
|
||||
|
||||
fetching = false;
|
||||
if (targetTimeline !== timelineRef.current) return;
|
||||
if (alive()) {
|
||||
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||
}
|
||||
|
@ -462,11 +463,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 +1005,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 +1036,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 +1069,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) => {
|
||||
|
@ -1070,13 +1100,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
if (typeof mxcUrl !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
|
||||
// const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
|
||||
|
||||
return (
|
||||
<Attachment>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
// height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<ImageContent
|
||||
|
|
|
@ -98,7 +98,13 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
</Overlay>
|
||||
)}
|
||||
{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 && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
|
@ -115,7 +121,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={css.AbsoluteContainer}>
|
||||
<Box className={css.AbsoluteContainer} style={{position: 'unset'}}>
|
||||
<Image
|
||||
alt={body}
|
||||
title={body}
|
||||
|
|
|
@ -43,7 +43,6 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const key = evt.currentTarget.getAttribute('data-reaction-key');
|
||||
console.log(key);
|
||||
if (!key) setViewer(true);
|
||||
else setViewer(key);
|
||||
};
|
||||
|
@ -58,7 +57,7 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||
>
|
||||
{reactions.map(([key, 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 isPressed = !!myREvent?.getRelation();
|
||||
|
||||
|
|
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>
|
||||
);
|
||||
});
|
|
@ -88,7 +88,13 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
{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 && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
|
|
|
@ -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%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -41,7 +41,12 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
|
|||
relations,
|
||||
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) =>
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
|
@ -68,16 +73,19 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
|
|||
<Box shrink="No" className={css.Sidebar}>
|
||||
<Scroll visibility="Hover" hideTrack size="300">
|
||||
<Box className={css.SidebarContent} direction="Column" gap="200">
|
||||
{reactions.map(([key, evts]) => (
|
||||
<Reaction
|
||||
key={key}
|
||||
mx={mx}
|
||||
reaction={key}
|
||||
count={evts.size}
|
||||
aria-selected={key === selectedKey}
|
||||
onClick={() => setSelectedKey(key)}
|
||||
/>
|
||||
))}
|
||||
{reactions.map(([key, evts]) => {
|
||||
if (typeof key !== 'string') return null;
|
||||
return (
|
||||
<Reaction
|
||||
key={key}
|
||||
mx={mx}
|
||||
reaction={key}
|
||||
count={evts.size}
|
||||
aria-selected={key === selectedKey}
|
||||
onClick={() => setSelectedKey(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
|
|
|
@ -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={(
|
||||
|
|
|
@ -248,13 +248,13 @@ const HeadingRule: BlockMDRule = {
|
|||
};
|
||||
|
||||
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 = {
|
||||
match: (text) => text.match(CODEBLOCK_REG_1),
|
||||
html: (match) => {
|
||||
const [, g1, g2] = match;
|
||||
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_TYPE = /^([aAiI])\./;
|
||||
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 = {
|
||||
match: (text) => text.match(ORDERED_LIST_REG_1),
|
||||
html: (match, parseInline) => {
|
|
@ -18,11 +18,11 @@ import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
|
|||
import { getMemberDisplayName } from '../utils/room';
|
||||
import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex';
|
||||
import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
|
||||
import { replaceMatch } from '../utils/markdown';
|
||||
import { findAndReplace } from '../utils/findAndReplace';
|
||||
|
||||
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 = {
|
||||
attributes: {
|
||||
|
@ -35,26 +35,22 @@ export const LINKIFY_OPTS: LinkifyOpts = {
|
|||
ignoreTags: ['span'],
|
||||
};
|
||||
|
||||
const stringToEmojifyJSX = (text: string): (string | JSX.Element)[] => {
|
||||
const match = text.match(EMOJI_REG);
|
||||
if (!match) return [text];
|
||||
|
||||
const [emoji] = match;
|
||||
|
||||
return replaceMatch(
|
||||
stringToEmojifyJSX,
|
||||
const textToEmojifyJSX = (text: string): (string | JSX.Element)[] =>
|
||||
findAndReplace(
|
||||
text,
|
||||
match,
|
||||
<span className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(emoji))}>
|
||||
{emoji}
|
||||
EMOJI_REG_G,
|
||||
(match, pushIndex) => (
|
||||
<span key={pushIndex} className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
||||
{match[0]}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
(txt) => txt
|
||||
);
|
||||
};
|
||||
|
||||
export const emojifyAndLinkify = (text: string, linkify?: boolean) => {
|
||||
const emojifyJSX = stringToEmojifyJSX(text);
|
||||
const emojifyJSX = textToEmojifyJSX(text);
|
||||
|
||||
if (linkify) {
|
||||
return <Linkify options={LINKIFY_OPTS}>{emojifyJSX}</Linkify>;
|
||||
|
|
|
@ -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,
|
||||
|
|
28
src/app/utils/findAndReplace.ts
Normal file
28
src/app/utils/findAndReplace.ts
Normal 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;
|
||||
};
|
|
@ -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)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const cons = {
|
||||
version: '3.1.0',
|
||||
version: '3.2.0',
|
||||
secretKey: {
|
||||
ACCESS_TOKEN: 'cinny_access_token',
|
||||
DEVICE_ID: 'cinny_device_id',
|
||||
|
|
Loading…
Reference in a new issue