Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cef074c9e | ||
|
|
b24f858369 | ||
|
|
72bb5b42af | ||
|
|
f53bb28b66 | ||
|
|
2957a45c4b | ||
|
|
c7e5c1fce8 | ||
|
|
8731f58948 | ||
|
|
7b64258af6 | ||
|
|
122ff2d216 | ||
|
|
c0abb0d50d | ||
|
|
1ff312d236 |
2
.github/workflows/build-pull-request.yml
vendored
2
.github/workflows/build-pull-request.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.5.3
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3.6.0
|
||||
uses: actions/setup-node@v3.8.1
|
||||
with:
|
||||
node-version: 18.12.1
|
||||
cache: "npm"
|
||||
|
||||
4
.github/workflows/deploy-pull-request.yml
vendored
4
.github/workflows/deploy-pull-request.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
path: dist
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@5da65c9f74c7961c5501a3ba329b8d0912f39c03
|
||||
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||
timeout-minutes: 1
|
||||
- name: Comment preview on PR
|
||||
uses: thollander/actions-comment-pull-request@dadb7667129e23f12ca3925c90dc5cd7121ab57e
|
||||
uses: thollander/actions-comment-pull-request@1d3973dc4b8e1399c0620d3f2b1aa5e795465308
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
4
.github/workflows/netlify-dev.yml
vendored
4
.github/workflows/netlify-dev.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.5.3
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3.6.0
|
||||
uses: actions/setup-node@v3.8.1
|
||||
with:
|
||||
node-version: 18.12.1
|
||||
cache: "npm"
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@5da65c9f74c7961c5501a3ba329b8d0912f39c03
|
||||
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Dev deploy ${{ github.sha }}"
|
||||
|
||||
4
.github/workflows/prod-deploy.yml
vendored
4
.github/workflows/prod-deploy.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.5.3
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3.6.0
|
||||
uses: actions/setup-node@v3.8.1
|
||||
with:
|
||||
node-version: 18.12.1
|
||||
cache: "npm"
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@5da65c9f74c7961c5501a3ba329b8d0912f39c03
|
||||
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Prod deploy ${{ github.ref_name }}"
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cinny",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cinny",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "4.5.14",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cinny",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
|
||||
@@ -66,3 +66,7 @@ export const EditorToolbarBase = style({
|
||||
export const EditorToolbar = style({
|
||||
padding: config.space.S100,
|
||||
});
|
||||
|
||||
export const MarkdownBtnBox = style({
|
||||
paddingRight: config.space.S100,
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import {
|
||||
headingLevel,
|
||||
isAnyMarkActive,
|
||||
isBlockActive,
|
||||
isMarkActive,
|
||||
@@ -31,6 +32,8 @@ import { BlockType, MarkType } from './types';
|
||||
import { HeadingLevel } from './slate';
|
||||
import { isMacOS } from '../../utils/user-agent';
|
||||
import { KeySymbol } from '../../utils/key-symbol';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
|
||||
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
|
||||
return (
|
||||
@@ -115,13 +118,13 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
||||
|
||||
export function HeadingBlockButton() {
|
||||
const editor = useSlate();
|
||||
const [level, setLevel] = useState<HeadingLevel>(1);
|
||||
const level = headingLevel(editor);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isActive = isBlockActive(editor, BlockType.Heading);
|
||||
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
||||
|
||||
const handleMenuSelect = (selectedLevel: HeadingLevel) => {
|
||||
setOpen(false);
|
||||
setLevel(selectedLevel);
|
||||
toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
|
||||
ReactEditor.focus(editor);
|
||||
};
|
||||
@@ -130,7 +133,6 @@ export function HeadingBlockButton() {
|
||||
<PopOut
|
||||
open={open}
|
||||
offset={5}
|
||||
align="Start"
|
||||
position="Top"
|
||||
content={
|
||||
<FocusTrap
|
||||
@@ -145,15 +147,51 @@ export function HeadingBlockButton() {
|
||||
>
|
||||
<Menu style={{ padding: config.space.S100 }}>
|
||||
<Box gap="100">
|
||||
<IconButton onClick={() => handleMenuSelect(1)} size="400" radii="300">
|
||||
<Icon size="200" src={Icons.Heading1} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleMenuSelect(2)} size="400" radii="300">
|
||||
<Icon size="200" src={Icons.Heading2} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleMenuSelect(3)} size="400" radii="300">
|
||||
<Icon size="200" src={Icons.Heading3} />
|
||||
</IconButton>
|
||||
<TooltipProvider
|
||||
tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + 1`} />}
|
||||
delay={500}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(1)}
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons.Heading1} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
tooltip={<BtnTooltip text="Heading 2" shortCode={`${modKey} + 2`} />}
|
||||
delay={500}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(2)}
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons.Heading2} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
tooltip={<BtnTooltip text="Heading 3" shortCode={`${modKey} + 3`} />}
|
||||
delay={500}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(3)}
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons.Heading3} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
@@ -169,7 +207,7 @@ export function HeadingBlockButton() {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons[`Heading${level}`]} />
|
||||
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
|
||||
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -210,8 +248,10 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
||||
export function Toolbar() {
|
||||
const editor = useSlate();
|
||||
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
||||
const disableInline = isBlockActive(editor, BlockType.CodeBlock);
|
||||
|
||||
const canEscape = isAnyMarkActive(editor) || !isBlockActive(editor, BlockType.Paragraph);
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
|
||||
return (
|
||||
<Box className={css.EditorToolbarBase}>
|
||||
@@ -237,12 +277,7 @@ export function Toolbar() {
|
||||
<MarkButton
|
||||
format={MarkType.StrikeThrough}
|
||||
icon={Icons.Strike}
|
||||
tooltip={
|
||||
<BtnTooltip
|
||||
text="Strike Through"
|
||||
shortCode={`${modKey} + ${KeySymbol.Shift} + U`}
|
||||
/>
|
||||
}
|
||||
tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`} />}
|
||||
/>
|
||||
<MarkButton
|
||||
format={MarkType.Code}
|
||||
@@ -292,6 +327,28 @@ export function Toolbar() {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
|
||||
<TooltipProvider
|
||||
align="End"
|
||||
tooltip={<BtnTooltip text="Toggle Markdown" />}
|
||||
delay={500}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={() => setIsMarkdown(!isMarkdown)}
|
||||
aria-pressed={isMarkdown}
|
||||
size="300"
|
||||
radii="300"
|
||||
disabled={disableInline || !!isAnyMarkActive(editor)}
|
||||
>
|
||||
<Icon size="200" src={Icons.Markdown} filled={isMarkdown} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<span />
|
||||
</Box>
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
|
||||
@@ -13,11 +13,9 @@ import {
|
||||
HeadingElement,
|
||||
HeadingLevel,
|
||||
InlineElement,
|
||||
ListItemElement,
|
||||
MentionElement,
|
||||
OrderedListElement,
|
||||
ParagraphElement,
|
||||
QuoteLineElement,
|
||||
UnorderedListElement,
|
||||
} from './slate';
|
||||
import { parseMatrixToUrl } from '../../utils/matrix';
|
||||
@@ -117,17 +115,14 @@ const parseInlineNodes = (node: ChildNode): InlineElement[] => {
|
||||
return [];
|
||||
};
|
||||
|
||||
const parseBlockquoteNode = (node: Element): BlockQuoteElement => {
|
||||
const children: QuoteLineElement[] = [];
|
||||
const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
|
||||
const quoteLines: Array<InlineElement[]> = [];
|
||||
let lineHolder: InlineElement[] = [];
|
||||
|
||||
const appendLine = () => {
|
||||
if (lineHolder.length === 0) return;
|
||||
|
||||
children.push({
|
||||
type: BlockType.QuoteLine,
|
||||
children: lineHolder,
|
||||
});
|
||||
quoteLines.push(lineHolder);
|
||||
lineHolder = [];
|
||||
};
|
||||
|
||||
@@ -138,16 +133,14 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement => {
|
||||
}
|
||||
if (isTag(child)) {
|
||||
if (child.name === 'br') {
|
||||
lineHolder.push({ text: '' });
|
||||
appendLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (child.name === 'p') {
|
||||
appendLine();
|
||||
children.push({
|
||||
type: BlockType.QuoteLine,
|
||||
children: child.children.flatMap((c) => parseInlineNodes(c)),
|
||||
});
|
||||
quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,42 +149,71 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement => {
|
||||
});
|
||||
appendLine();
|
||||
|
||||
return {
|
||||
type: BlockType.BlockQuote,
|
||||
children,
|
||||
};
|
||||
};
|
||||
const parseCodeBlockNode = (node: Element): CodeBlockElement => {
|
||||
const children: CodeLineElement[] = [];
|
||||
if (node.attribs['data-md'] !== undefined) {
|
||||
return quoteLines.map((lineChildren) => ({
|
||||
type: BlockType.Paragraph,
|
||||
children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
|
||||
}));
|
||||
}
|
||||
|
||||
const code = parseNodeText(node).trim();
|
||||
code.split('\n').forEach((lineTxt) =>
|
||||
children.push({
|
||||
type: BlockType.CodeLine,
|
||||
return [
|
||||
{
|
||||
type: BlockType.BlockQuote,
|
||||
children: quoteLines.map((lineChildren) => ({
|
||||
type: BlockType.QuoteLine,
|
||||
children: lineChildren,
|
||||
})),
|
||||
},
|
||||
];
|
||||
};
|
||||
const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
|
||||
const codeLines = parseNodeText(node).trim().split('\n');
|
||||
|
||||
if (node.attribs['data-md'] !== undefined) {
|
||||
const pLines = codeLines.map<ParagraphElement>((lineText) => ({
|
||||
type: BlockType.Paragraph,
|
||||
children: [
|
||||
{
|
||||
text: lineTxt,
|
||||
text: lineText,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}));
|
||||
const childCode = node.children[0];
|
||||
const className =
|
||||
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
|
||||
const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` };
|
||||
const suffix = { text: node.attribs['data-md'] };
|
||||
return [
|
||||
{ type: BlockType.Paragraph, children: [prefix] },
|
||||
...pLines,
|
||||
{ type: BlockType.Paragraph, children: [suffix] },
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
type: BlockType.CodeBlock,
|
||||
children,
|
||||
};
|
||||
return [
|
||||
{
|
||||
type: BlockType.CodeBlock,
|
||||
children: codeLines.map<CodeLineElement>((lineTxt) => ({
|
||||
type: BlockType.CodeLine,
|
||||
children: [
|
||||
{
|
||||
text: lineTxt,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
];
|
||||
};
|
||||
const parseListNode = (node: Element): OrderedListElement | UnorderedListElement => {
|
||||
const children: ListItemElement[] = [];
|
||||
const parseListNode = (
|
||||
node: Element
|
||||
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
|
||||
const listLines: Array<InlineElement[]> = [];
|
||||
let lineHolder: InlineElement[] = [];
|
||||
|
||||
const appendLine = () => {
|
||||
if (lineHolder.length === 0) return;
|
||||
|
||||
children.push({
|
||||
type: BlockType.ListItem,
|
||||
children: lineHolder,
|
||||
});
|
||||
listLines.push(lineHolder);
|
||||
lineHolder = [];
|
||||
};
|
||||
|
||||
@@ -202,16 +224,14 @@ const parseListNode = (node: Element): OrderedListElement | UnorderedListElement
|
||||
}
|
||||
if (isTag(child)) {
|
||||
if (child.name === 'br') {
|
||||
lineHolder.push({ text: '' });
|
||||
appendLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (child.name === 'li') {
|
||||
appendLine();
|
||||
children.push({
|
||||
type: BlockType.ListItem,
|
||||
children: child.children.flatMap((c) => parseInlineNodes(c)),
|
||||
});
|
||||
listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,17 +240,54 @@ const parseListNode = (node: Element): OrderedListElement | UnorderedListElement
|
||||
});
|
||||
appendLine();
|
||||
|
||||
return {
|
||||
type: node.name === 'ol' ? BlockType.OrderedList : BlockType.UnorderedList,
|
||||
children,
|
||||
};
|
||||
if (node.attribs['data-md'] !== undefined) {
|
||||
const prefix = node.attribs['data-md'] || '-';
|
||||
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
|
||||
return listLines.map((lineChildren) => ({
|
||||
type: BlockType.Paragraph,
|
||||
children: [
|
||||
{ text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` },
|
||||
...lineChildren,
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
if (node.name === 'ol') {
|
||||
return [
|
||||
{
|
||||
type: BlockType.OrderedList,
|
||||
children: listLines.map((lineChildren) => ({
|
||||
type: BlockType.ListItem,
|
||||
children: lineChildren,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: BlockType.UnorderedList,
|
||||
children: listLines.map((lineChildren) => ({
|
||||
type: BlockType.ListItem,
|
||||
children: lineChildren,
|
||||
})),
|
||||
},
|
||||
];
|
||||
};
|
||||
const parseHeadingNode = (node: Element): HeadingElement => {
|
||||
const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
|
||||
const children = node.children.flatMap((child) => parseInlineNodes(child));
|
||||
|
||||
const headingMatch = node.name.match(/^h([123456])$/);
|
||||
const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
|
||||
const level = parseInt(g1AsLevel, 10);
|
||||
|
||||
if (node.attribs['data-md'] !== undefined) {
|
||||
return {
|
||||
type: BlockType.Paragraph,
|
||||
children: [{ text: `${node.attribs['data-md']} ` }, ...children],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: BlockType.Heading,
|
||||
level: (level <= 3 ? level : 3) as HeadingLevel,
|
||||
@@ -260,6 +317,7 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
|
||||
}
|
||||
if (isTag(node)) {
|
||||
if (node.name === 'br') {
|
||||
lineHolder.push({ text: '' });
|
||||
appendLine();
|
||||
return;
|
||||
}
|
||||
@@ -275,17 +333,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
|
||||
|
||||
if (node.name === 'blockquote') {
|
||||
appendLine();
|
||||
children.push(parseBlockquoteNode(node));
|
||||
children.push(...parseBlockquoteNode(node));
|
||||
return;
|
||||
}
|
||||
if (node.name === 'pre') {
|
||||
appendLine();
|
||||
children.push(parseCodeBlockNode(node));
|
||||
children.push(...parseCodeBlockNode(node));
|
||||
return;
|
||||
}
|
||||
if (node.name === 'ol' || node.name === 'ul') {
|
||||
appendLine();
|
||||
children.push(parseListNode(node));
|
||||
children.push(...parseListNode(node));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const INLINE_HOTKEYS: Record<string, MarkType> = {
|
||||
'mod+b': MarkType.Bold,
|
||||
'mod+i': MarkType.Italic,
|
||||
'mod+u': MarkType.Underline,
|
||||
'mod+shift+u': MarkType.StrikeThrough,
|
||||
'mod+s': MarkType.StrikeThrough,
|
||||
'mod+[': MarkType.Code,
|
||||
'mod+h': MarkType.Spoiler,
|
||||
};
|
||||
@@ -21,6 +21,9 @@ export const BLOCK_HOTKEYS: Record<string, BlockType> = {
|
||||
'mod+;': BlockType.CodeBlock,
|
||||
};
|
||||
const BLOCK_KEYS = Object.keys(BLOCK_HOTKEYS);
|
||||
const isHeading1 = isKeyHotkey('mod+1');
|
||||
const isHeading2 = isKeyHotkey('mod+2');
|
||||
const isHeading3 = isKeyHotkey('mod+3');
|
||||
|
||||
/**
|
||||
* @return boolean true if shortcut is toggled.
|
||||
@@ -86,6 +89,18 @@ export const toggleKeyboardShortcut = (editor: Editor, event: KeyboardEvent<Elem
|
||||
return false;
|
||||
});
|
||||
if (blockToggled) return true;
|
||||
if (isHeading1(event)) {
|
||||
toggleBlock(editor, BlockType.Heading, { level: 1 });
|
||||
return true;
|
||||
}
|
||||
if (isHeading2(event)) {
|
||||
toggleBlock(editor, BlockType.Heading, { level: 2 });
|
||||
return true;
|
||||
}
|
||||
if (isHeading3(event)) {
|
||||
toggleBlock(editor, BlockType.Heading, { level: 3 });
|
||||
return true;
|
||||
}
|
||||
|
||||
const inlineToggled = isBlockActive(editor, BlockType.CodeBlock)
|
||||
? false
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Descendant, Text } from 'slate';
|
||||
import { sanitizeText } from '../../utils/sanitize';
|
||||
import { BlockType } from './types';
|
||||
import { CustomElement } from './slate';
|
||||
import { parseInlineMD } from '../../utils/markdown';
|
||||
import { parseBlockMD, parseInlineMD, replaceMatch } from '../../utils/markdown';
|
||||
|
||||
export type OutputOptions = {
|
||||
allowTextFormatting?: boolean;
|
||||
allowMarkdown?: boolean;
|
||||
allowInlineMarkdown?: boolean;
|
||||
allowBlockMarkdown?: boolean;
|
||||
};
|
||||
|
||||
const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
|
||||
@@ -21,7 +22,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
|
||||
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
|
||||
}
|
||||
|
||||
if (opts.allowMarkdown && string === sanitizeText(node.text)) {
|
||||
if (opts.allowInlineMarkdown && string === sanitizeText(node.text)) {
|
||||
string = parseInlineMD(string);
|
||||
}
|
||||
|
||||
@@ -64,14 +65,42 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
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('');
|
||||
};
|
||||
|
||||
export const toMatrixCustomHTML = (
|
||||
node: Descendant | Descendant[],
|
||||
opts: OutputOptions
|
||||
): string => {
|
||||
const parseNode = (n: Descendant) => {
|
||||
let markdownLines = '';
|
||||
const parseNode = (n: Descendant, index: number, targetNodes: Descendant[]) => {
|
||||
if (opts.allowBlockMarkdown && 'type' in n && n.type === BlockType.Paragraph) {
|
||||
const line = toMatrixCustomHTML(n, {
|
||||
...opts,
|
||||
allowInlineMarkdown: false,
|
||||
allowBlockMarkdown: false,
|
||||
})
|
||||
.replace(/<br\/>$/, '\n')
|
||||
.replace(/^>/, '>');
|
||||
markdownLines += line;
|
||||
if (index === targetNodes.length - 1) {
|
||||
return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsedMarkdown = parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
|
||||
markdownLines = '';
|
||||
const isCodeLine = 'type' in n && n.type === BlockType.CodeLine;
|
||||
if (isCodeLine) return toMatrixCustomHTML(n, {});
|
||||
return toMatrixCustomHTML(n, opts);
|
||||
if (isCodeLine) return `${parsedMarkdown}${toMatrixCustomHTML(n, {})}`;
|
||||
|
||||
return `${parsedMarkdown}${toMatrixCustomHTML(n, { ...opts, allowBlockMarkdown: false })}`;
|
||||
};
|
||||
if (Array.isArray(node)) return node.map(parseNode).join('');
|
||||
if (Text.isText(node)) return textToCustomHtml(node, opts);
|
||||
|
||||
@@ -52,6 +52,16 @@ export const isBlockActive = (editor: Editor, format: BlockType) => {
|
||||
return !!match;
|
||||
};
|
||||
|
||||
export const headingLevel = (editor: Editor): HeadingLevel | undefined => {
|
||||
const [nodeEntry] = Editor.nodes(editor, {
|
||||
match: (node) => Element.isElement(node) && node.type === BlockType.Heading,
|
||||
});
|
||||
const [node] = nodeEntry ?? [];
|
||||
if (!node) return undefined;
|
||||
if ('level' in node) return node.level;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type BlockOption = { level: HeadingLevel };
|
||||
const NESTED_BLOCK = [
|
||||
BlockType.OrderedList,
|
||||
|
||||
@@ -68,6 +68,7 @@ export type EmojiItemInfo = {
|
||||
type: EmojiType;
|
||||
data: string;
|
||||
shortcode: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`;
|
||||
@@ -75,13 +76,15 @@ const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`;
|
||||
const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
|
||||
const type = element.getAttribute('data-emoji-type') as EmojiType | undefined;
|
||||
const data = element.getAttribute('data-emoji-data');
|
||||
const label = element.getAttribute('title');
|
||||
const shortcode = element.getAttribute('data-emoji-shortcode');
|
||||
|
||||
if (type && data && shortcode)
|
||||
if (type && data && shortcode && label)
|
||||
return {
|
||||
type,
|
||||
data,
|
||||
shortcode,
|
||||
label,
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
@@ -633,7 +636,7 @@ export function EmojiBoard({
|
||||
returnFocusOnDeactivate?: boolean;
|
||||
onEmojiSelect?: (unicode: string, shortcode: string) => void;
|
||||
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
|
||||
onStickerSelect?: (mxc: string, shortcode: string) => void;
|
||||
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
||||
allowTextCustomEmoji?: boolean;
|
||||
}) {
|
||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||
@@ -712,7 +715,7 @@ export function EmojiBoard({
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.Sticker) {
|
||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
}
|
||||
};
|
||||
@@ -783,7 +786,7 @@ export function EmojiBoard({
|
||||
data-emoji-board-search
|
||||
variant="SurfaceVariant"
|
||||
size="400"
|
||||
placeholder="Search"
|
||||
placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'}
|
||||
maxLength={50}
|
||||
after={
|
||||
allowTextCustomEmoji && result?.query ? (
|
||||
@@ -791,6 +794,7 @@ export function EmojiBoard({
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
after={<Icon src={Icons.ArrowRight} size="50" />}
|
||||
outlined
|
||||
onClick={() => {
|
||||
const searchInput = document.querySelector<HTMLInputElement>(
|
||||
'[data-emoji-board-search="true"]'
|
||||
|
||||
@@ -59,6 +59,9 @@ export const Reply = as<'div', ReplyProps>(
|
||||
};
|
||||
}, [replyEvent, mx, room, eventId]);
|
||||
|
||||
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
|
||||
const bodyJSX = body ? trimReplyFromBody(body) : fallbackBody;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.Reply, className)}
|
||||
@@ -67,7 +70,11 @@ export const Reply = as<'div', ReplyProps>(
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Box style={{ color: colorMXID(sender ?? eventId) }} alignItems="Center" shrink="No">
|
||||
<Box
|
||||
style={{ color: colorMXID(sender ?? eventId), maxWidth: '50%' }}
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
>
|
||||
<Icon src={Icons.ReplyArrow} size="50" />
|
||||
{sender && (
|
||||
<Text size="T300" truncate>
|
||||
@@ -78,11 +85,7 @@ export const Reply = as<'div', ReplyProps>(
|
||||
<Box grow="Yes" className={css.ReplyContent}>
|
||||
{replyEvent !== undefined ? (
|
||||
<Text className={css.ReplyContentText} size="T300" truncate>
|
||||
{replyEvent?.getContent().msgtype === 'm.bad.encrypted' ? (
|
||||
<MessageBadEncryptedContent />
|
||||
) : (
|
||||
(body && trimReplyFromBody(body)) ?? fallbackBody
|
||||
)}
|
||||
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
|
||||
</Text>
|
||||
) : (
|
||||
<LinePlaceholder
|
||||
|
||||
@@ -118,8 +118,8 @@ export const CompactHeader = style([
|
||||
|
||||
export const AvatarBase = style({
|
||||
paddingTop: toRem(4),
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||
alignSelf: 'start',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
|
||||
@@ -10,6 +10,7 @@ export const TypingIndicator = as<'div', TypingIndicatorProps>(({ size, style, .
|
||||
<Box
|
||||
as="span"
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
|
||||
{...props}
|
||||
ref={ref}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { MessageEvent, StateEvent } from '../../types/matrix/room';
|
||||
import { isMembershipChanged } from '../utils/room';
|
||||
import { isMembershipChanged, reactionOrEditEvent } from '../utils/room';
|
||||
|
||||
export const useRoomLatestRenderedEvent = (room: Room) => {
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
@@ -19,7 +19,7 @@ export const useRoomLatestRenderedEvent = (room: Room) => {
|
||||
const evt = liveEvents[i];
|
||||
|
||||
if (!evt) continue;
|
||||
if (evt.isRelation()) continue;
|
||||
if (reactionOrEditEvent(evt)) continue;
|
||||
if (evt.getType() === StateEvent.RoomMember) {
|
||||
const membershipChanged = isMembershipChanged(evt);
|
||||
if (membershipChanged && hideMembershipEvents) continue;
|
||||
|
||||
@@ -271,7 +271,7 @@ export function MembersDrawer({ room }: MembersDrawerProps) {
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H5" truncate>
|
||||
{`${millify(room.getJoinedMemberCount(), { precision: 1 })} Members`}
|
||||
{`${millify(room.getJoinedMemberCount(), { precision: 1, locales: [] })} Members`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import to from 'await-to-js';
|
||||
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
@@ -216,30 +215,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
};
|
||||
|
||||
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
||||
const sendPromises = uploads.map(async (upload) => {
|
||||
const contentsPromises = uploads.map(async (upload) => {
|
||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||
if (fileItem && fileItem.file.type.startsWith('image')) {
|
||||
const [imgError, imgContent] = await to(getImageMsgContent(mx, fileItem, upload.mxc));
|
||||
if (imgError) console.warn(imgError);
|
||||
if (imgContent) mx.sendMessage(roomId, imgContent);
|
||||
return;
|
||||
if (!fileItem) throw new Error('Broken upload');
|
||||
|
||||
if (fileItem.file.type.startsWith('image')) {
|
||||
return getImageMsgContent(mx, fileItem, upload.mxc);
|
||||
}
|
||||
if (fileItem && fileItem.file.type.startsWith('video')) {
|
||||
const [videoError, videoContent] = await to(getVideoMsgContent(mx, fileItem, upload.mxc));
|
||||
if (videoError) console.warn(videoError);
|
||||
if (videoContent) mx.sendMessage(roomId, videoContent);
|
||||
return;
|
||||
if (fileItem.file.type.startsWith('video')) {
|
||||
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
||||
}
|
||||
if (fileItem && fileItem.file.type.startsWith('audio')) {
|
||||
mx.sendMessage(roomId, getAudioMsgContent(fileItem, upload.mxc));
|
||||
return;
|
||||
}
|
||||
if (fileItem) {
|
||||
mx.sendMessage(roomId, getFileMsgContent(fileItem, upload.mxc));
|
||||
if (fileItem.file.type.startsWith('audio')) {
|
||||
return getAudioMsgContent(fileItem, upload.mxc);
|
||||
}
|
||||
return getFileMsgContent(fileItem, upload.mxc);
|
||||
});
|
||||
handleCancelUpload(uploads);
|
||||
await Promise.allSettled(sendPromises);
|
||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||
contents.forEach((content) => mx.sendMessage(roomId, content));
|
||||
};
|
||||
|
||||
const submit = useCallback(() => {
|
||||
@@ -251,7 +244,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
let customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
allowTextFormatting: true,
|
||||
allowMarkdown: isMarkdown,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
})
|
||||
);
|
||||
let msgType = MsgType.Text;
|
||||
@@ -319,7 +313,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (enterForNewline ? isKeyHotkey('shift+enter', evt) : isKeyHotkey('enter', evt)) {
|
||||
if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
|
||||
evt.preventDefault();
|
||||
submit();
|
||||
}
|
||||
@@ -359,7 +353,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
moveCursor(editor);
|
||||
};
|
||||
|
||||
const handleStickerSelect = async (mxc: string, shortcode: string) => {
|
||||
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
|
||||
const stickerUrl = mx.mxcUrlToHttp(mxc);
|
||||
if (!stickerUrl) return;
|
||||
|
||||
@@ -369,7 +363,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
);
|
||||
|
||||
mx.sendEvent(roomId, EventType.Sticker, {
|
||||
body: shortcode,
|
||||
body: label,
|
||||
url: mxc,
|
||||
info,
|
||||
});
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import Linkify from 'linkify-react';
|
||||
import {
|
||||
decryptFile,
|
||||
eventWithShortcode,
|
||||
@@ -76,7 +75,10 @@ import {
|
||||
MessageBadEncryptedContent,
|
||||
MessageNotDecryptedContent,
|
||||
} from '../../components/message';
|
||||
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
|
||||
import {
|
||||
emojifyAndLinkify,
|
||||
getReactCustomHtmlParser,
|
||||
} from '../../plugins/react-custom-html-parser';
|
||||
import {
|
||||
canEditEvent,
|
||||
decryptAllTimelineEvent,
|
||||
@@ -86,6 +88,8 @@ import {
|
||||
getMemberDisplayName,
|
||||
getReactionContent,
|
||||
isMembershipChanged,
|
||||
reactionOrEditEvent,
|
||||
trimReplyFromBody,
|
||||
} from '../../utils/room';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -134,6 +138,13 @@ 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';
|
||||
|
||||
// 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 TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||
({ position, className, ...props }, ref) => (
|
||||
@@ -331,6 +342,7 @@ const useTimelinePagination = (
|
||||
|
||||
return async (backwards: boolean) => {
|
||||
if (fetching) return;
|
||||
const targetTimeline = timelineRef.current;
|
||||
const { linkedTimelines: lTimelines } = timelineRef.current;
|
||||
const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
|
||||
|
||||
@@ -370,6 +382,7 @@ const useTimelinePagination = (
|
||||
}
|
||||
|
||||
fetching = false;
|
||||
if (targetTimeline !== timelineRef.current) return;
|
||||
if (alive()) {
|
||||
recalibratePagination(lTimelines, timelinesEventsCount, backwards);
|
||||
}
|
||||
@@ -579,7 +592,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
focusItem.current = {
|
||||
index: evtAbsIndex,
|
||||
scrollTo: true,
|
||||
highlight: evtId !== unreadInfo?.readUptoEventId,
|
||||
highlight: evtId !== readUptoEventIdRef.current,
|
||||
};
|
||||
setTimeline({
|
||||
linkedTimelines: lTimelines,
|
||||
@@ -589,7 +602,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
},
|
||||
});
|
||||
},
|
||||
[unreadInfo, alive]
|
||||
[alive]
|
||||
),
|
||||
useCallback(() => {
|
||||
if (!alive()) return;
|
||||
@@ -615,6 +628,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
if (document.hasFocus()) {
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
} else if (!unreadInfo) {
|
||||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
}
|
||||
setTimeline((ct) => ({
|
||||
...ct,
|
||||
@@ -919,7 +934,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
if (!replyEvt) return;
|
||||
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
||||
const { body, formatted_body: formattedBody }: Record<string, string> =
|
||||
editedReply?.getContent()['m.new.content'] ?? replyEvt.getContent();
|
||||
editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||
const senderId = replyEvt.getSender();
|
||||
if (senderId && typeof body === 'string') {
|
||||
setReplyDraft({
|
||||
@@ -975,22 +990,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
if (customBody === '') <MessageEmptyContent />;
|
||||
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
|
||||
}
|
||||
return <Linkify options={LINKIFY_OPTS}>{body}</Linkify>;
|
||||
return emojifyAndLinkify(body, true);
|
||||
};
|
||||
|
||||
const renderRoomMsgContent = useRoomMsgContentRenderer<[EventTimelineSet]>({
|
||||
renderText: (mEventId, mEvent, timelineSet) => {
|
||||
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||
const { body, formatted_body: customBody }: Record<string, unknown> =
|
||||
editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
|
||||
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
|
||||
|
||||
if (typeof body !== 'string') return null;
|
||||
const jumboEmoji = JUMBO_EMOJI_REG.test(trimReplyFromBody(body));
|
||||
|
||||
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"
|
||||
>
|
||||
@@ -1002,7 +1021,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
renderEmote: (mEventId, mEvent, timelineSet) => {
|
||||
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||
const { body, formatted_body: customBody } =
|
||||
editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
|
||||
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
|
||||
const senderDisplayName =
|
||||
@@ -1027,7 +1046,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
renderNotice: (mEventId, mEvent, timelineSet) => {
|
||||
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||
const { body, formatted_body: customBody }: Record<string, unknown> =
|
||||
editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
|
||||
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
|
||||
|
||||
if (typeof body !== 'string') return null;
|
||||
return (
|
||||
@@ -1630,7 +1649,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
prevEvent.getType() === mEvent.getType() &&
|
||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
||||
|
||||
const eventJSX = mEvent.isRelation()
|
||||
const eventJSX = reactionOrEditEvent(mEvent)
|
||||
? null
|
||||
: renderMatrixEvent(mEventId, mEvent, item, timelineSet, collapsed);
|
||||
prevEvent = mEvent;
|
||||
|
||||
@@ -44,7 +44,8 @@ export const AudioContent = as<'div', AudioContentProps>(
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(info.duration ?? 0);
|
||||
// duration in seconds. (NOTE: info.duration is in milliseconds)
|
||||
const [duration, setDuration] = useState((info.duration ?? 0) / 1000);
|
||||
|
||||
const getAudioRef = useCallback(() => audioRef.current, []);
|
||||
const { loading } = useMediaLoading(getAudioRef);
|
||||
|
||||
@@ -392,7 +392,7 @@ export const MessageDeleteItem = as<
|
||||
variant="Critical"
|
||||
before={
|
||||
deleteState.status === AsyncStatus.Loading ? (
|
||||
<Spinner fill="Soft" variant="Critical" size="200" />
|
||||
<Spinner fill="Solid" variant="Critical" size="200" />
|
||||
) : undefined
|
||||
}
|
||||
aria-disabled={deleteState.status === AsyncStatus.Loading}
|
||||
@@ -522,7 +522,7 @@ export const MessageReportItem = as<
|
||||
variant="Critical"
|
||||
before={
|
||||
reportState.status === AsyncStatus.Loading ? (
|
||||
<Spinner fill="Soft" variant="Critical" size="200" />
|
||||
<Spinner fill="Solid" variant="Critical" size="200" />
|
||||
) : undefined
|
||||
}
|
||||
aria-disabled={
|
||||
@@ -654,7 +654,13 @@ export const Message = as<'div', MessageProps>(
|
||||
|
||||
const avatarJSX = !collapse && messageLayout !== 1 && (
|
||||
<AvatarBase>
|
||||
<Avatar as="button" size="300" data-user-id={senderId} onClick={onUserClick}>
|
||||
<Avatar
|
||||
className={css.MessageAvatar}
|
||||
as="button"
|
||||
size="300"
|
||||
data-user-id={senderId}
|
||||
onClick={onUserClick}
|
||||
>
|
||||
{senderAvatarMxc ? (
|
||||
<AvatarImage
|
||||
src={mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? senderAvatarMxc}
|
||||
@@ -696,7 +702,7 @@ export const Message = as<'div', MessageProps>(
|
||||
);
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
|
||||
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
|
||||
const tag = (evt.target as any).tagName;
|
||||
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
||||
evt.preventDefault();
|
||||
|
||||
@@ -53,16 +53,22 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
|
||||
const getPrevBodyAndFormattedBody = useCallback(() => {
|
||||
const getPrevBodyAndFormattedBody = useCallback((): [
|
||||
string | undefined,
|
||||
string | undefined
|
||||
] => {
|
||||
const evtId = mEvent.getId()!;
|
||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
||||
const editedEvent =
|
||||
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
|
||||
|
||||
const { body, formatted_body: customHtml }: Record<string, unknown> =
|
||||
editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
|
||||
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
|
||||
|
||||
return [body, customHtml];
|
||||
return [
|
||||
typeof body === 'string' ? body : undefined,
|
||||
typeof customHtml === 'string' ? customHtml : undefined,
|
||||
];
|
||||
}, [room, mEvent]);
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
@@ -71,21 +77,25 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
const customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
allowTextFormatting: true,
|
||||
allowMarkdown: isMarkdown,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
})
|
||||
);
|
||||
|
||||
const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
|
||||
|
||||
if (plainText === '') return undefined;
|
||||
if (
|
||||
typeof prevCustomHtml === 'string' &&
|
||||
trimReplyFromFormattedBody(prevCustomHtml) === customHtml
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (!prevCustomHtml && typeof prevBody === 'string' && prevBody === plainText) {
|
||||
return undefined;
|
||||
if (prevBody) {
|
||||
if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!prevCustomHtml &&
|
||||
prevBody === plainText &&
|
||||
customHtmlEqualsPlainText(customHtml, plainText)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const newContent: IContent = {
|
||||
@@ -120,7 +130,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (enterForNewline ? isKeyHotkey('shift+enter', evt) : isKeyHotkey('enter', evt)) {
|
||||
if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
|
||||
evt.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||
<Text size="T300">
|
||||
<Text className={css.ReactionsTooltipText} size="T300">
|
||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
|
||||
@@ -56,6 +56,10 @@ export const MessageOptionsBar = style([
|
||||
},
|
||||
]);
|
||||
|
||||
export const MessageAvatar = style({
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const MessageQuickReaction = style({
|
||||
minWidth: toRem(32),
|
||||
});
|
||||
@@ -75,3 +79,7 @@ export const ReactionsContainer = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ReactionsTooltipText = style({
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
|
||||
@@ -54,23 +54,10 @@ export const getImageMsgContent = async (
|
||||
};
|
||||
if (imgEl) {
|
||||
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
|
||||
const [thumbError, thumbContent] = await to(
|
||||
generateThumbnailContent(
|
||||
mx,
|
||||
imgEl,
|
||||
getThumbnailDimensions(imgEl.width, imgEl.height),
|
||||
!!encInfo
|
||||
)
|
||||
);
|
||||
|
||||
if (thumbContent && thumbContent.thumbnail_info) {
|
||||
thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = blurHash;
|
||||
}
|
||||
if (thumbError) console.warn(thumbError);
|
||||
content.info = {
|
||||
...getImageInfo(imgEl, file),
|
||||
[MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
|
||||
...thumbContent,
|
||||
};
|
||||
}
|
||||
if (encInfo) {
|
||||
|
||||
@@ -45,6 +45,8 @@ import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { isMacOS } from '../../utils/user-agent';
|
||||
import { KeySymbol } from '../../utils/key-symbol';
|
||||
|
||||
function AppearanceSection() {
|
||||
const [, updateState] = useState({});
|
||||
@@ -52,7 +54,7 @@ function AppearanceSection() {
|
||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||
const [useSystemEmoji, setUseSystemEmoji] = useSetting(settingsAtom, 'useSystemEmoji');
|
||||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||
@@ -94,14 +96,14 @@ function AppearanceSection() {
|
||||
)}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Use System Emoji"
|
||||
title="Use Twitter Emoji"
|
||||
options={(
|
||||
<Toggle
|
||||
isActive={useSystemEmoji}
|
||||
onToggle={() => setUseSystemEmoji(!useSystemEmoji)}
|
||||
isActive={twitterEmoji}
|
||||
onToggle={() => setTwitterEmoji(!twitterEmoji)}
|
||||
/>
|
||||
)}
|
||||
content={<Text variant="b3">Use system emoji instead of Twitter emojis.</Text>}
|
||||
content={<Text variant="b3">Use Twitter emoji instead of system emoji.</Text>}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-appearance__card">
|
||||
@@ -147,17 +149,17 @@ function AppearanceSection() {
|
||||
onToggle={() => setEnterForNewline(!enterForNewline) }
|
||||
/>
|
||||
)}
|
||||
content={<Text variant="b3">Use SHIFT + ENTER to send message and ENTER for newline.</Text>}
|
||||
content={<Text variant="b3">{`Use ${isMacOS() ? KeySymbol.Command : 'Ctrl'} + ENTER to send message and ENTER for newline.`}</Text>}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Inline Markdown formatting"
|
||||
title="Markdown formatting"
|
||||
options={(
|
||||
<Toggle
|
||||
isActive={isMarkdown}
|
||||
onToggle={() => setIsMarkdown(!isMarkdown) }
|
||||
/>
|
||||
)}
|
||||
content={<Text variant="b3">Format messages with inline markdown syntax before sending.</Text>}
|
||||
content={<Text variant="b3">Format messages with markdown syntax before sending.</Text>}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Hide membership events"
|
||||
@@ -337,6 +339,10 @@ function AboutSection() {
|
||||
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
|
||||
<Text>The <a href="https://github.com/matrix-org/matrix-js-sdk" rel="noreferrer noopener" target="_blank">matrix-js-sdk</a> is © <a href="https://matrix.org/foundation" rel="noreferrer noopener" target="_blank">The Matrix.org Foundation C.I.C</a> used under the terms of <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener" target="_blank">Apache 2.0</a>.</Text>
|
||||
</li>
|
||||
<li>
|
||||
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
|
||||
<Text>The <a href="https://github.com/mozilla/twemoji-colr" target="_blank" rel="noreferrer noopener">twemoji-colr</a> font is © <a href="https://mozilla.org/" target="_blank" rel="noreferrer noopener">Mozilla Foundation</a> used under the terms of <a href="http://www.apache.org/licenses/LICENSE-2.0" target="_blank" rel="noreferrer noopener">Apache 2.0</a>.</Text>
|
||||
</li>
|
||||
<li>
|
||||
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
|
||||
<Text>The <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twemoji</a> emoji art is © <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twitter, Inc and other contributors</a> used under the terms of <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noreferrer noopener">CC-BY 4.0</a>.</Text>
|
||||
|
||||
@@ -16,9 +16,14 @@ import { ErrorBoundary } from 'react-error-boundary';
|
||||
import * as css from '../styles/CustomHtml.css';
|
||||
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';
|
||||
|
||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
||||
|
||||
const EMOJI_REG = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`);
|
||||
|
||||
export const LINKIFY_OPTS: LinkifyOpts = {
|
||||
attributes: {
|
||||
target: '_blank',
|
||||
@@ -27,6 +32,34 @@ export const LINKIFY_OPTS: LinkifyOpts = {
|
||||
validate: {
|
||||
url: (value) => /^(https|http|ftp|mailto|magnet)?:/.test(value),
|
||||
},
|
||||
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,
|
||||
text,
|
||||
match,
|
||||
<span className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(emoji))}>
|
||||
{emoji}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const emojifyAndLinkify = (text: string, linkify?: boolean) => {
|
||||
const emojifyJSX = stringToEmojifyJSX(text);
|
||||
|
||||
if (linkify) {
|
||||
return <Linkify options={LINKIFY_OPTS}>{emojifyJSX}</Linkify>;
|
||||
}
|
||||
return emojifyJSX;
|
||||
};
|
||||
|
||||
export const getReactCustomHtmlParser = (
|
||||
@@ -45,7 +78,7 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'h1') {
|
||||
return (
|
||||
<Text className={css.Heading} size="H2" {...props}>
|
||||
<Text {...props} className={css.Heading} size="H2">
|
||||
{domToReact(children, opts)}
|
||||
</Text>
|
||||
);
|
||||
@@ -53,7 +86,7 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'h2') {
|
||||
return (
|
||||
<Text className={css.Heading} size="H3" {...props}>
|
||||
<Text {...props} className={css.Heading} size="H3">
|
||||
{domToReact(children, opts)}
|
||||
</Text>
|
||||
);
|
||||
@@ -61,7 +94,7 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'h3') {
|
||||
return (
|
||||
<Text className={css.Heading} size="H4" {...props}>
|
||||
<Text {...props} className={css.Heading} size="H4">
|
||||
{domToReact(children, opts)}
|
||||
</Text>
|
||||
);
|
||||
@@ -69,7 +102,7 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'h4') {
|
||||
return (
|
||||
<Text className={css.Heading} size="H4" {...props}>
|
||||
<Text {...props} className={css.Heading} size="H4">
|
||||
{domToReact(children, opts)}
|
||||
</Text>
|
||||
);
|
||||
@@ -77,7 +110,7 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'h5') {
|
||||
return (
|
||||
<Text className={css.Heading} size="H5" {...props}>
|
||||
<Text {...props} className={css.Heading} size="H5">
|
||||
{domToReact(children, opts)}
|
||||
</Text>
|
||||
);
|
||||
@@ -85,7 +118,7 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'h6') {
|
||||
return (
|
||||
<Text className={css.Heading} size="H6" {...props}>
|
||||
<Text {...props} className={css.Heading} size="H6">
|
||||
{domToReact(children, opts)}
|
||||
</Text>
|
||||
);
|
||||
@@ -93,7 +126,7 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'p') {
|
||||
return (
|
||||
<Text className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit" {...props}>
|
||||
<Text {...props} className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit">
|
||||
{domToReact(children, opts)}
|
||||
</Text>
|
||||
);
|
||||
@@ -101,7 +134,7 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'pre') {
|
||||
return (
|
||||
<Text as="pre" className={css.CodeBlock} {...props}>
|
||||
<Text {...props} as="pre" className={css.CodeBlock}>
|
||||
<Scroll
|
||||
direction="Horizontal"
|
||||
variant="Secondary"
|
||||
@@ -117,7 +150,7 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'blockquote') {
|
||||
return (
|
||||
<Text size="Inherit" as="blockquote" className={css.BlockQuote} {...props}>
|
||||
<Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}>
|
||||
{domToReact(children, opts)}
|
||||
</Text>
|
||||
);
|
||||
@@ -125,14 +158,14 @@ export const getReactCustomHtmlParser = (
|
||||
|
||||
if (name === 'ul') {
|
||||
return (
|
||||
<ul className={css.List} {...props}>
|
||||
<ul {...props} className={css.List}>
|
||||
{domToReact(children, opts)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (name === 'ol') {
|
||||
return (
|
||||
<ol className={css.List} {...props}>
|
||||
<ol {...props} className={css.List}>
|
||||
{domToReact(children, opts)}
|
||||
</ol>
|
||||
);
|
||||
@@ -144,6 +177,8 @@ export const getReactCustomHtmlParser = (
|
||||
if (typeof codeReact === 'string') {
|
||||
let lang = props.className;
|
||||
if (lang === 'language-rs') lang = 'language-rust';
|
||||
else if (lang === 'language-js') lang = 'language-javascript';
|
||||
else if (lang === 'language-ts') lang = 'language-typescript';
|
||||
return (
|
||||
<ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
|
||||
<Suspense fallback={<code {...props}>{codeReact}</code>}>
|
||||
@@ -240,29 +275,28 @@ export const getReactCustomHtmlParser = (
|
||||
if (htmlSrc && props.src.startsWith('mxc://') === false) {
|
||||
return (
|
||||
<a href={htmlSrc} target="_blank" rel="noreferrer noopener">
|
||||
{props.alt && htmlSrc}
|
||||
{props.alt || props.title || htmlSrc}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (htmlSrc && 'data-mx-emoticon' in props) {
|
||||
return (
|
||||
<span className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()} contentEditable={false}>
|
||||
<img className={css.EmoticonImg} src={htmlSrc} data-mx-emoticon />
|
||||
<span className={css.Emoticon()}>
|
||||
<img {...props} className={css.EmoticonImg} src={htmlSrc} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (htmlSrc) return <img className={css.Img} {...props} src={htmlSrc} />;
|
||||
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
domNode instanceof DOMText &&
|
||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
|
||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a')
|
||||
) {
|
||||
return <Linkify options={LINKIFY_OPTS}>{domNode.data}</Linkify>;
|
||||
if (domNode instanceof DOMText) {
|
||||
const linkify =
|
||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
|
||||
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a');
|
||||
return emojifyAndLinkify(domNode.data, linkify);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface Settings {
|
||||
useSystemTheme: boolean;
|
||||
isMarkdown: boolean;
|
||||
editorToolbar: boolean;
|
||||
useSystemEmoji: boolean;
|
||||
twitterEmoji: boolean;
|
||||
|
||||
isPeopleDrawer: boolean;
|
||||
memberSortFilterIndex: number;
|
||||
@@ -28,9 +28,9 @@ export interface Settings {
|
||||
const defaultSettings: Settings = {
|
||||
themeIndex: 0,
|
||||
useSystemTheme: true,
|
||||
isMarkdown: false,
|
||||
isMarkdown: true,
|
||||
editorToolbar: false,
|
||||
useSystemEmoji: false,
|
||||
twitterEmoji: false,
|
||||
|
||||
isPeopleDrawer: true,
|
||||
memberSortFilterIndex: 0,
|
||||
|
||||
@@ -187,11 +187,11 @@ export const Emoticon = recipe({
|
||||
|
||||
height: '1em',
|
||||
minWidth: '1em',
|
||||
fontSize: '1.47em',
|
||||
fontSize: '1.33em',
|
||||
lineHeight: '1em',
|
||||
verticalAlign: 'middle',
|
||||
position: 'relative',
|
||||
top: '-0.32em',
|
||||
top: '-0.35em',
|
||||
borderRadius: config.radii.R300,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -24,12 +24,12 @@ import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
|
||||
function SystemEmojiFeature() {
|
||||
const [systemEmoji] = useSetting(settingsAtom, 'useSystemEmoji');
|
||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
|
||||
if (systemEmoji) {
|
||||
document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
|
||||
} else {
|
||||
if (twitterEmoji) {
|
||||
document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,63 +1,90 @@
|
||||
export type PlainMDParser = (text: string) => string;
|
||||
export type MatchResult = RegExpMatchArray | RegExpExecArray;
|
||||
export type RuleMatch = (text: string) => MatchResult | null;
|
||||
export type MatchConverter = (parse: PlainMDParser, match: MatchResult) => string;
|
||||
|
||||
export type MDRule = {
|
||||
match: RuleMatch;
|
||||
html: MatchConverter;
|
||||
};
|
||||
export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
|
||||
text.slice(0, match.index);
|
||||
export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
|
||||
text.slice((match.index ?? 0) + match[0].length);
|
||||
|
||||
export type MatchReplacer = (
|
||||
parse: PlainMDParser,
|
||||
export const replaceMatch = <C>(
|
||||
convertPart: (txt: string) => Array<string | C>,
|
||||
text: string,
|
||||
match: MatchResult,
|
||||
content: string
|
||||
) => string;
|
||||
content: C
|
||||
): Array<string | C> => [
|
||||
...convertPart(beforeMatch(text, match)),
|
||||
content,
|
||||
...convertPart(afterMatch(text, match)),
|
||||
];
|
||||
|
||||
export type RuleRunner = (parse: PlainMDParser, text: string, rule: MDRule) => string | undefined;
|
||||
export type RulesRunner = (
|
||||
parse: PlainMDParser,
|
||||
/*
|
||||
*****************
|
||||
* INLINE PARSER *
|
||||
*****************
|
||||
*/
|
||||
|
||||
export type InlineMDParser = (text: string) => string;
|
||||
|
||||
export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string;
|
||||
|
||||
export type InlineMDRule = {
|
||||
match: RuleMatch;
|
||||
html: InlineMatchConverter;
|
||||
};
|
||||
|
||||
export type InlineRuleRunner = (
|
||||
parse: InlineMDParser,
|
||||
text: string,
|
||||
rules: MDRule[]
|
||||
rule: InlineMDRule
|
||||
) => string | undefined;
|
||||
export type InlineRulesRunner = (
|
||||
parse: InlineMDParser,
|
||||
text: string,
|
||||
rules: InlineMDRule[]
|
||||
) => string | undefined;
|
||||
|
||||
const MIN_ANY = '(.+?)';
|
||||
const URL_NEG_LB = '(?<!(https?|ftp|mailto|magnet):\\/\\/\\S*)';
|
||||
|
||||
const BOLD_MD_1 = '**';
|
||||
const BOLD_PREFIX_1 = '\\*{2}';
|
||||
const BOLD_NEG_LA_1 = '(?!\\*)';
|
||||
const BOLD_REG_1 = new RegExp(`${BOLD_PREFIX_1}${MIN_ANY}${BOLD_PREFIX_1}${BOLD_NEG_LA_1}`);
|
||||
const BoldRule: MDRule = {
|
||||
const BOLD_REG_1 = new RegExp(
|
||||
`${URL_NEG_LB}${BOLD_PREFIX_1}${MIN_ANY}${BOLD_PREFIX_1}${BOLD_NEG_LA_1}`
|
||||
);
|
||||
const BoldRule: InlineMDRule = {
|
||||
match: (text) => text.match(BOLD_REG_1),
|
||||
html: (parse, match) => {
|
||||
const [, g1] = match;
|
||||
const child = parse(g1);
|
||||
return `<strong data-md="${BOLD_MD_1}">${child}</strong>`;
|
||||
const [, , g2] = match;
|
||||
return `<strong data-md="${BOLD_MD_1}">${parse(g2)}</strong>`;
|
||||
},
|
||||
};
|
||||
|
||||
const ITALIC_MD_1 = '*';
|
||||
const ITALIC_PREFIX_1 = '\\*';
|
||||
const ITALIC_NEG_LA_1 = '(?!\\*)';
|
||||
const ITALIC_REG_1 = new RegExp(`${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}`);
|
||||
const ItalicRule1: MDRule = {
|
||||
const ITALIC_REG_1 = new RegExp(
|
||||
`${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}`
|
||||
);
|
||||
const ItalicRule1: InlineMDRule = {
|
||||
match: (text) => text.match(ITALIC_REG_1),
|
||||
html: (parse, match) => {
|
||||
const [, g1] = match;
|
||||
return `<i data-md="${ITALIC_MD_1}">${parse(g1)}</i>`;
|
||||
const [, , g2] = match;
|
||||
return `<i data-md="${ITALIC_MD_1}">${parse(g2)}</i>`;
|
||||
},
|
||||
};
|
||||
|
||||
const ITALIC_MD_2 = '_';
|
||||
const ITALIC_PREFIX_2 = '_';
|
||||
const ITALIC_NEG_LA_2 = '(?!_)';
|
||||
const ITALIC_REG_2 = new RegExp(`${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}`);
|
||||
const ItalicRule2: MDRule = {
|
||||
const ITALIC_REG_2 = new RegExp(
|
||||
`${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}`
|
||||
);
|
||||
const ItalicRule2: InlineMDRule = {
|
||||
match: (text) => text.match(ITALIC_REG_2),
|
||||
html: (parse, match) => {
|
||||
const [, g1] = match;
|
||||
return `<i data-md="${ITALIC_MD_2}">${parse(g1)}</i>`;
|
||||
const [, , g2] = match;
|
||||
return `<i data-md="${ITALIC_MD_2}">${parse(g2)}</i>`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -65,37 +92,39 @@ const UNDERLINE_MD_1 = '__';
|
||||
const UNDERLINE_PREFIX_1 = '_{2}';
|
||||
const UNDERLINE_NEG_LA_1 = '(?!_)';
|
||||
const UNDERLINE_REG_1 = new RegExp(
|
||||
`${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}`
|
||||
`${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}`
|
||||
);
|
||||
const UnderlineRule: MDRule = {
|
||||
const UnderlineRule: InlineMDRule = {
|
||||
match: (text) => text.match(UNDERLINE_REG_1),
|
||||
html: (parse, match) => {
|
||||
const [, g1] = match;
|
||||
return `<u data-md="${UNDERLINE_MD_1}">${parse(g1)}</u>`;
|
||||
const [, , g2] = match;
|
||||
return `<u data-md="${UNDERLINE_MD_1}">${parse(g2)}</u>`;
|
||||
},
|
||||
};
|
||||
|
||||
const STRIKE_MD_1 = '~~';
|
||||
const STRIKE_PREFIX_1 = '~{2}';
|
||||
const STRIKE_NEG_LA_1 = '(?!~)';
|
||||
const STRIKE_REG_1 = new RegExp(`${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}`);
|
||||
const StrikeRule: MDRule = {
|
||||
const STRIKE_REG_1 = new RegExp(
|
||||
`${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}`
|
||||
);
|
||||
const StrikeRule: InlineMDRule = {
|
||||
match: (text) => text.match(STRIKE_REG_1),
|
||||
html: (parse, match) => {
|
||||
const [, g1] = match;
|
||||
return `<del data-md="${STRIKE_MD_1}">${parse(g1)}</del>`;
|
||||
const [, , g2] = match;
|
||||
return `<del data-md="${STRIKE_MD_1}">${parse(g2)}</del>`;
|
||||
},
|
||||
};
|
||||
|
||||
const CODE_MD_1 = '`';
|
||||
const CODE_PREFIX_1 = '`';
|
||||
const CODE_NEG_LA_1 = '(?!`)';
|
||||
const CODE_REG_1 = new RegExp(`${CODE_PREFIX_1}${MIN_ANY}${CODE_PREFIX_1}${CODE_NEG_LA_1}`);
|
||||
const CodeRule: MDRule = {
|
||||
const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`);
|
||||
const CodeRule: InlineMDRule = {
|
||||
match: (text) => text.match(CODE_REG_1),
|
||||
html: (parse, match) => {
|
||||
const [, g1] = match;
|
||||
return `<code data-md="${CODE_MD_1}">${g1}</code>`;
|
||||
const [, , g2] = match;
|
||||
return `<code data-md="${CODE_MD_1}">${g2}</code>`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -103,20 +132,20 @@ const SPOILER_MD_1 = '||';
|
||||
const SPOILER_PREFIX_1 = '\\|{2}';
|
||||
const SPOILER_NEG_LA_1 = '(?!\\|)';
|
||||
const SPOILER_REG_1 = new RegExp(
|
||||
`${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}`
|
||||
`${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}`
|
||||
);
|
||||
const SpoilerRule: MDRule = {
|
||||
const SpoilerRule: InlineMDRule = {
|
||||
match: (text) => text.match(SPOILER_REG_1),
|
||||
html: (parse, match) => {
|
||||
const [, g1] = match;
|
||||
return `<span data-md="${SPOILER_MD_1}" data-mx-spoiler>${parse(g1)}</span>`;
|
||||
const [, , g2] = match;
|
||||
return `<span data-md="${SPOILER_MD_1}" data-mx-spoiler>${parse(g2)}</span>`;
|
||||
},
|
||||
};
|
||||
|
||||
const LINK_ALT = `\\[${MIN_ANY}\\]`;
|
||||
const LINK_URL = `\\((https?:\\/\\/.+?)\\)`;
|
||||
const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`);
|
||||
const LinkRule: MDRule = {
|
||||
const LinkRule: InlineMDRule = {
|
||||
match: (text) => text.match(LINK_REG_1),
|
||||
html: (parse, match) => {
|
||||
const [, g1, g2] = match;
|
||||
@@ -124,19 +153,11 @@ const LinkRule: MDRule = {
|
||||
},
|
||||
};
|
||||
|
||||
const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
|
||||
text.slice(0, match.index);
|
||||
const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
|
||||
text.slice((match.index ?? 0) + match[0].length);
|
||||
|
||||
const replaceMatch: MatchReplacer = (parse, text, match, content) =>
|
||||
`${parse(beforeMatch(text, match))}${content}${parse(afterMatch(text, match))}`;
|
||||
|
||||
const runRule: RuleRunner = (parse, text, rule) => {
|
||||
const runInlineRule: InlineRuleRunner = (parse, text, rule) => {
|
||||
const matchResult = rule.match(text);
|
||||
if (matchResult) {
|
||||
const content = rule.html(parse, matchResult);
|
||||
return replaceMatch(parse, text, matchResult, content);
|
||||
return replaceMatch((txt) => [parse(txt)], text, matchResult, content).join('');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -145,10 +166,10 @@ const runRule: RuleRunner = (parse, text, rule) => {
|
||||
* Runs multiple rules at the same time to better handle nested rules.
|
||||
* Rules will be run in the order they appear.
|
||||
*/
|
||||
const runRules: RulesRunner = (parse, text, rules) => {
|
||||
const runInlineRules: InlineRulesRunner = (parse, text, rules) => {
|
||||
const matchResults = rules.map((rule) => rule.match(text));
|
||||
|
||||
let targetRule: MDRule | undefined;
|
||||
let targetRule: InlineMDRule | undefined;
|
||||
let targetResult: MatchResult | undefined;
|
||||
|
||||
for (let i = 0; i < matchResults.length; i += 1) {
|
||||
@@ -166,7 +187,7 @@ const runRules: RulesRunner = (parse, text, rules) => {
|
||||
|
||||
if (targetRule && targetResult) {
|
||||
const content = targetRule.html(parse, targetResult);
|
||||
return replaceMatch(parse, text, targetResult, content);
|
||||
return replaceMatch((txt) => [parse(txt)], text, targetResult, content).join('');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -181,11 +202,167 @@ const LeveledRules = [
|
||||
LinkRule,
|
||||
];
|
||||
|
||||
export const parseInlineMD = (text: string): string => {
|
||||
export const parseInlineMD: InlineMDParser = (text) => {
|
||||
if (text === '') return text;
|
||||
let result: string | undefined;
|
||||
if (!result) result = runRule(parseInlineMD, text, CodeRule);
|
||||
if (!result) result = runInlineRule(parseInlineMD, text, CodeRule);
|
||||
|
||||
if (!result) result = runRules(parseInlineMD, text, LeveledRules);
|
||||
if (!result) result = runInlineRules(parseInlineMD, text, LeveledRules);
|
||||
|
||||
return result ?? text;
|
||||
};
|
||||
|
||||
/*
|
||||
****************
|
||||
* BLOCK PARSER *
|
||||
****************
|
||||
*/
|
||||
|
||||
export type BlockMDParser = (test: string, parseInline?: (txt: string) => string) => string;
|
||||
|
||||
export type BlockMatchConverter = (
|
||||
match: MatchResult,
|
||||
parseInline?: (txt: string) => string
|
||||
) => string;
|
||||
|
||||
export type BlockMDRule = {
|
||||
match: RuleMatch;
|
||||
html: BlockMatchConverter;
|
||||
};
|
||||
|
||||
export type BlockRuleRunner = (
|
||||
parse: BlockMDParser,
|
||||
text: string,
|
||||
rule: BlockMDRule,
|
||||
parseInline?: (txt: string) => string
|
||||
) => string | undefined;
|
||||
|
||||
const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m;
|
||||
const HeadingRule: BlockMDRule = {
|
||||
match: (text) => text.match(HEADING_REG_1),
|
||||
html: (match, parseInline) => {
|
||||
const [, g1, g2] = match;
|
||||
const level = g1.length;
|
||||
return `<h${level} data-md="${g1}">${parseInline ? parseInline(g2) : g2}</h${level}>`;
|
||||
},
|
||||
};
|
||||
|
||||
const CODEBLOCK_MD_1 = '```';
|
||||
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>`;
|
||||
},
|
||||
};
|
||||
|
||||
const BLOCKQUOTE_MD_1 = '>';
|
||||
const QUOTE_LINE_PREFIX = /^> */;
|
||||
const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/;
|
||||
const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m;
|
||||
const BlockQuoteRule: BlockMDRule = {
|
||||
match: (text) => text.match(BLOCKQUOTE_REG_1),
|
||||
html: (match, parseInline) => {
|
||||
const [blockquoteText] = match;
|
||||
|
||||
const lines = blockquoteText
|
||||
.replace(BLOCKQUOTE_TRAILING_NEWLINE, '')
|
||||
.split('\n')
|
||||
.map((lineText) => {
|
||||
const line = lineText.replace(QUOTE_LINE_PREFIX, '');
|
||||
if (parseInline) return `${parseInline(line)}<br/>`;
|
||||
return `${line}<br/>`;
|
||||
})
|
||||
.join('');
|
||||
return `<blockquote data-md="${BLOCKQUOTE_MD_1}">${lines}</blockquote>`;
|
||||
},
|
||||
};
|
||||
|
||||
const ORDERED_LIST_MD_1 = '-';
|
||||
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 OrderedListRule: BlockMDRule = {
|
||||
match: (text) => text.match(ORDERED_LIST_REG_1),
|
||||
html: (match, parseInline) => {
|
||||
const [listText] = match;
|
||||
const [, listStart] = listText.match(O_LIST_START) ?? [];
|
||||
const [, listType] = listText.match(O_LIST_TYPE) ?? [];
|
||||
|
||||
const lines = listText
|
||||
.replace(O_LIST_TRAILING_NEWLINE, '')
|
||||
.split('\n')
|
||||
.map((lineText) => {
|
||||
const line = lineText.replace(O_LIST_ITEM_PREFIX, '');
|
||||
const txt = parseInline ? parseInline(line) : line;
|
||||
return `<li><p>${txt}</p></li>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`;
|
||||
const startAtt = listStart ? ` start="${listStart}"` : '';
|
||||
const typeAtt = listType ? ` type="${listType}"` : '';
|
||||
return `<ol ${dataMdAtt}${startAtt}${typeAtt}>${lines}</ol>`;
|
||||
},
|
||||
};
|
||||
|
||||
const UNORDERED_LIST_MD_1 = '*';
|
||||
const U_LIST_ITEM_PREFIX = /^\* */;
|
||||
const U_LIST_TRAILING_NEWLINE = /\n$/;
|
||||
const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m;
|
||||
const UnorderedListRule: BlockMDRule = {
|
||||
match: (text) => text.match(UNORDERED_LIST_REG_1),
|
||||
html: (match, parseInline) => {
|
||||
const [listText] = match;
|
||||
|
||||
const lines = listText
|
||||
.replace(U_LIST_TRAILING_NEWLINE, '')
|
||||
.split('\n')
|
||||
.map((lineText) => {
|
||||
const line = lineText.replace(U_LIST_ITEM_PREFIX, '');
|
||||
const txt = parseInline ? parseInline(line) : line;
|
||||
return `<li><p>${txt}</p></li>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<ul data-md="${UNORDERED_LIST_MD_1}">${lines}</ul>`;
|
||||
},
|
||||
};
|
||||
|
||||
const runBlockRule: BlockRuleRunner = (parse, text, rule, parseInline) => {
|
||||
const matchResult = rule.match(text);
|
||||
if (matchResult) {
|
||||
const content = rule.html(matchResult, parseInline);
|
||||
return replaceMatch((txt) => [parse(txt, parseInline)], text, matchResult, content).join('');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const parseBlockMD: BlockMDParser = (text, parseInline) => {
|
||||
if (text === '') return text;
|
||||
let result: string | undefined;
|
||||
|
||||
if (!result) result = runBlockRule(parseBlockMD, text, CodeBlockRule, parseInline);
|
||||
if (!result) result = runBlockRule(parseBlockMD, text, BlockQuoteRule, parseInline);
|
||||
if (!result) result = runBlockRule(parseBlockMD, text, OrderedListRule, parseInline);
|
||||
if (!result) result = runBlockRule(parseBlockMD, text, UnorderedListRule, parseInline);
|
||||
if (!result) result = runBlockRule(parseBlockMD, text, HeadingRule, parseInline);
|
||||
|
||||
// replace \n with <br/> because want to preserve empty lines
|
||||
if (!result) {
|
||||
if (parseInline) {
|
||||
result = text
|
||||
.split('\n')
|
||||
.map((lineText) => parseInline(lineText))
|
||||
.join('<br/>');
|
||||
} else {
|
||||
result = text.replace(/\n/g, '<br/>');
|
||||
}
|
||||
}
|
||||
|
||||
return result ?? text;
|
||||
};
|
||||
|
||||
7
src/app/utils/regex.ts
Normal file
7
src/app/utils/regex.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -256,7 +256,7 @@ export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefin
|
||||
};
|
||||
|
||||
export const trimReplyFromBody = (body: string): string => {
|
||||
const match = body.match(/^>\s<.+?>\s.+\n\n/);
|
||||
const match = body.match(/^> <.+?> .+\n(>.*\n)*?\n/m);
|
||||
if (!match) return body;
|
||||
return body.slice(match[0].length);
|
||||
};
|
||||
@@ -380,3 +380,7 @@ export const getLatestEditableEvt = (
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const reactionOrEditEvent = (mEvent: MatrixEvent) =>
|
||||
mEvent.getRelation()?.rel_type === RelationType.Annotation ||
|
||||
mEvent.getRelation()?.rel_type === RelationType.Replace;
|
||||
|
||||
@@ -59,9 +59,18 @@ const permittedTagToAttributes = {
|
||||
'data-md',
|
||||
],
|
||||
div: ['data-mx-maths'],
|
||||
blockquote: ['data-md'],
|
||||
h1: ['data-md'],
|
||||
h2: ['data-md'],
|
||||
h3: ['data-md'],
|
||||
h4: ['data-md'],
|
||||
h5: ['data-md'],
|
||||
h6: ['data-md'],
|
||||
pre: ['data-md', 'class'],
|
||||
ol: ['start', 'type', 'data-md'],
|
||||
ul: ['data-md'],
|
||||
a: ['name', 'target', 'href', 'rel', 'data-md'],
|
||||
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
|
||||
ol: ['start'],
|
||||
code: ['class', 'data-md'],
|
||||
strong: ['data-md'],
|
||||
i: ['data-md'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const cons = {
|
||||
version: '3.0.0',
|
||||
version: '3.1.0',
|
||||
secretKey: {
|
||||
ACCESS_TOKEN: 'cinny_access_token',
|
||||
DEVICE_ID: 'cinny_device_id',
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
--fluid-slide-down: cubic-bezier(0.02, 0.82, 0.4, 0.96);
|
||||
--fluid-slide-up: cubic-bezier(0.13, 0.56, 0.25, 0.99);
|
||||
|
||||
--font-emoji: 'Twemoji';
|
||||
--font-emoji: 'Twemoji_DISABLED';
|
||||
--font-primary: 'InterVariable', var(--font-emoji), sans-serif;
|
||||
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user