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",
|
"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",
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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[],
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
|
@ -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 ? (
|
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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
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 (
|
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">
|
||||||
|
|
|
@ -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%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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={(
|
||||||
|
|
|
@ -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) => {
|
|
@ -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>;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
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*)';
|
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)
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in a new issue