Compare commits

...

20 Commits

Author SHA1 Message Date
Krishan
9ecb233763 Release v3.2.0 (#1531)
* Release v3.2.0

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

* move findAndReplace func in its own file

* improve find and replace

* move markdown file to plugins

* make find and replace work without g flag regex

* fix pagination stop on msg arrive

* render blurhash in small size
2023-10-30 11:28:47 +05:30
Krishan
3713125f57 Fix grammer in membership event messages (#1520) 2023-10-30 11:28:30 +05:30
Ajay Bura
9f9173c691 Add URL preview (#1511)
* URL preview - WIP

* fix url preview regex

* update url match regex

* add url preview components

* add scroll btn url preview holder

* add message body component

* add url preview toggle in settings

* update url regex

* improve url regex

* increase thumbnail size in url preview

* hide url preview in encrypted rooms

* add encrypted room url preview toggle
2023-10-30 07:14:58 +11:00
Ajay Bura
a98903a85b Fix regex to ignore html tag in editor output (#1515) 2023-10-29 22:42:05 +11:00
Ajay Bura
a2cbe79787 Fix broken emoji with md pattern in shortcode (#1514)
* fix broken emoji with md pattern in shortcode

* fix html regex when generating editor output
2023-10-29 21:53:44 +11:00
Krishan
3cef074c9e Release v3.1.0 (#1510)
* Update package.json

* Update cons.js

* Update package-lock.json
2023-10-27 22:11:08 +11:00
Ajay Bura
b24f858369 Improve Editor related bugs and add multiline md (#1507)
* remove shift from editor hotkeys

* fix inline markdown not working

* add block md parser - WIP

* emojify and linkify text without react-parser

* no need to sanitize text when emojify

* parse block markdown in editor output - WIP

* add inline parser option in block md parser

* improve codeblock regex

* ignore html tag when parsing inline md in block md

* add list markdown rule in block parser

* re-generate block markdown on edit

* change copy from inline markdown to markdown

* fix trim reply from body regex

* fix jumbo emoji in reply message

* fix broken list regex in block markdown

* enable markdown by defualt
2023-10-27 21:27:22 +11:00
Ajay Bura
72bb5b42af Fix-timeline-loading (#1506)
* fix timeline jump to search item after markAsRead

* improve pagination logic

* add jumbo emoji support in msg rendering
2023-10-26 10:51:55 +05:30
Ajay Bura
f53bb28b66 Fix emoji and other related bugs (#1504)
* make system-emoji default & twitter emoji optional

* add mozilla twemoji-colr credit

* fix wrong audio duration

* set locales to empty in member count millify

* render system emoji as same size of custom emoji
2023-10-26 09:09:27 +11:00
Ajay Bura
2957a45c4b Room input improvements (#1502)
* prevent context menu when editing message

* send sticker body (#1479)

* update emojiboard search text reaction input label

* stop generating upload image thumbnail (#1475)

* maintain upload order

* Fix message options spinner variant

* add markdown toggle in editor toolbar

* fix heading toggle icon update with cursor move

* add hotkeys for heading

* change editor markdown btn style

* use Ctrl + Enter to send message (#1470)

* fix reaction tooltip word-break

* add shift in editor hokeys with number

* stop parsing markdown in link
2023-10-25 16:50:38 +11:00
Ajay Bura
c7e5c1fce8 Fix reply username overflow (#1501)
* fix reply overflow

* fix shrinkable typing indicator

* fix message avatar hover & cursor
2023-10-24 22:21:39 +11:00
dependabot[bot]
8731f58948 Bump nwtgck/actions-netlify from 2.0.0 to 2.1.0 (#1402)
Bumps [nwtgck/actions-netlify](https://github.com/nwtgck/actions-netlify) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/nwtgck/actions-netlify/releases)
- [Changelog](https://github.com/nwtgck/actions-netlify/blob/develop/CHANGELOG.md)
- [Commits](5da65c9f74...7a92f00dde)

---
updated-dependencies:
- dependency-name: nwtgck/actions-netlify
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-23 22:05:38 +11:00
dependabot[bot]
7b64258af6 Bump actions/setup-node from 3.6.0 to 3.8.1 (#1401)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3.6.0 to 3.8.1.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3.6.0...v3.8.1)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-23 22:03:00 +11:00
dependabot[bot]
122ff2d216 Bump thollander/actions-comment-pull-request from 2.4.0 to 2.4.3 (#1480)
Bumps [thollander/actions-comment-pull-request](https://github.com/thollander/actions-comment-pull-request) from 2.4.0 to 2.4.3.
- [Release notes](https://github.com/thollander/actions-comment-pull-request/releases)
- [Commits](dadb766712...1d3973dc4b)

---
updated-dependencies:
- dependency-name: thollander/actions-comment-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-23 21:49:38 +11:00
Ajay Bura
c0abb0d50d fix thread fallback (#1478) 2023-10-23 21:43:07 +11:00
Ajay Bura
1ff312d236 Fix edit related bugs (#1477)
* fix missing empty line on edit

* fix edit save after adding formatting to plaintext

* fix reading edit content with wrong key
2023-10-23 21:42:27 +11:00
48 changed files with 1347 additions and 456 deletions

View File

@@ -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"

View File

@@ -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:

View File

@@ -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 }}"

View File

@@ -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
View File

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

View File

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

View File

@@ -66,3 +66,7 @@ export const EditorToolbarBase = style({
export const EditorToolbar = style({
padding: config.space.S100,
});
export const MarkdownBtnBox = style({
paddingRight: config.space.S100,
});

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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

View File

@@ -3,11 +3,13 @@ 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 } from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace';
export type OutputOptions = {
allowTextFormatting?: boolean;
allowMarkdown?: boolean;
allowInlineMarkdown?: boolean;
allowBlockMarkdown?: boolean;
};
const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
@@ -21,7 +23,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);
}
@@ -50,28 +52,60 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
return `<ul>${children}</ul>`;
case BlockType.Mention:
return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`;
return `<a href="https://matrix.to/#/${encodeURIComponent(node.id)}">${sanitizeText(
node.name
)}</a>`;
case BlockType.Emoticon:
return node.key.startsWith('mxc://')
? `<img data-mx-emoticon src="${node.key}" alt="${node.shortcode}" title="${node.shortcode}" height="32">`
: node.key;
? `<img data-mx-emoticon src="${node.key}" alt="${sanitizeText(
node.shortcode
)}" title="${sanitizeText(node.shortcode)}" height="32" />`
: sanitizeText(node.key);
case BlockType.Link:
return `<a href="${node.href}">${node.children}</a>`;
return `<a href="${encodeURIComponent(node.href)}">${node.children}</a>`;
case BlockType.Command:
return `/${node.command}`;
return `/${sanitizeText(node.command)}`;
default:
return children;
}
};
const HTML_TAG_REG_G = /<([\w-]+)(?: [^>]*)?(?:(?:\/>)|(?:>.*?<\/\1>))/g;
const ignoreHTMLParseInlineMD = (text: string): string =>
findAndReplace(
text,
HTML_TAG_REG_G,
(match) => match[0],
(txt) => parseInlineMD(txt)
).join('');
export const toMatrixCustomHTML = (
node: Descendant | Descendant[],
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(/^&gt;/, '>');
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);

View File

@@ -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,

View File

@@ -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"]'

View File

@@ -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

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { as } from 'folds';
import { Text, as } from 'folds';
import classNames from 'classnames';
import * as css from './layout.css';
@@ -23,3 +23,16 @@ export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
));
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
<Text
as={asComp}
size="T400"
priority={notice ? '300' : '400'}
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
{...props}
ref={ref}
/>
)
);

View File

@@ -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': {
@@ -153,3 +153,30 @@ export const Username = style({
},
},
});
export const MessageTextBody = recipe({
base: {
wordBreak: 'break-word',
},
variants: {
preWrap: {
true: {
whiteSpace: 'pre-wrap',
},
},
jumboEmoji: {
true: {
fontSize: '1.504em',
lineHeight: '1.4962em',
},
},
emote: {
true: {
color: color.Success.Main,
fontStyle: 'italic',
},
},
},
});
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;

View File

@@ -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}

View File

@@ -0,0 +1,45 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const UrlPreview = style([
DefaultReset,
{
width: toRem(400),
minHeight: toRem(102),
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
overflow: 'hidden',
},
]);
export const UrlPreviewImg = style([
DefaultReset,
{
width: toRem(100),
height: toRem(100),
objectFit: 'cover',
objectPosition: 'left',
backgroundPosition: 'start',
flexShrink: 0,
overflow: 'hidden',
},
]);
export const UrlPreviewContent = style([
DefaultReset,
{
padding: config.space.S200,
},
]);
export const UrlPreviewDescription = style([
DefaultReset,
{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
},
]);

View File

@@ -0,0 +1,27 @@
import React from 'react';
import classNames from 'classnames';
import { Box, as } from 'folds';
import * as css from './UrlPreview.css';
export const UrlPreview = as<'div'>(({ className, ...props }, ref) => (
<Box shrink="No" className={classNames(css.UrlPreview, className)} {...props} ref={ref} />
));
export const UrlPreviewImg = as<'img'>(({ className, alt, ...props }, ref) => (
<img className={classNames(css.UrlPreviewImg, className)} alt={alt} {...props} ref={ref} />
));
export const UrlPreviewContent = as<'div'>(({ className, ...props }, ref) => (
<Box
grow="Yes"
direction="Column"
gap="100"
className={classNames(css.UrlPreviewContent, className)}
{...props}
ref={ref}
/>
));
export const UrlPreviewDescription = as<'span'>(({ className, ...props }, ref) => (
<span className={classNames(css.UrlPreviewDescription, className)} {...props} ref={ref} />
));

View File

@@ -0,0 +1 @@
export * from './UrlPreview';

View File

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

View File

@@ -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;

View File

@@ -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">

View File

@@ -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,
});

View File

@@ -44,7 +44,6 @@ import {
toRem,
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import Linkify from 'linkify-react';
import {
decryptFile,
eventWithShortcode,
@@ -75,8 +74,12 @@ import {
Time,
MessageBadEncryptedContent,
MessageNotDecryptedContent,
MessageTextBody,
} 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 +89,8 @@ import {
getMemberDisplayName,
getReactionContent,
isMembershipChanged,
reactionOrEditEvent,
trimReplyFromBody,
} from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -134,6 +139,15 @@ import initMatrix from '../../../client/initMatrix';
import { useKeyDown } from '../../hooks/useKeyDown';
import cons from '../../../client/state/cons';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { EMOJI_PATTERN, HTTP_URL_PATTERN, VARIATION_SELECTOR_PATTERN } from '../../utils/regex';
import { UrlPreviewCard, UrlPreviewHolder } from './message/UrlPreviewCard';
// Thumbs up emoji found to have Variation Selector 16 at the end
// so included variation selector pattern in regex
const JUMBO_EMOJI_REG = new RegExp(
`^(((${EMOJI_PATTERN})|(:.+?:))(${VARIATION_SELECTOR_PATTERN}|\\s)*){1,10}$`
);
const URL_REG = new RegExp(HTTP_URL_PATTERN, 'g');
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
@@ -449,11 +463,15 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
const mx = useMatrixClient();
const encryptedRoom = mx.isRoomEncrypted(room.roomId);
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI();
@@ -579,7 +597,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 +607,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
},
});
},
[unreadInfo, alive]
[alive]
),
useCallback(() => {
if (!alive()) return;
@@ -615,6 +633,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 +939,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,73 +995,102 @@ 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 trimmedBody = trimReplyFromBody(body);
const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<Text
as="div"
style={{
whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap',
wordBreak: 'break-word',
}}
priority="400"
>
{renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
{!!editedEvent && <MessageEditedContent />}
</Text>
<>
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
{!!editedEvent && <MessageEditedContent />}
</MessageTextBody>
{urls && urls.length > 0 && (
<UrlPreviewHolder>
{urls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
))}
</UrlPreviewHolder>
)}
</>
);
},
renderEmote: (mEventId, mEvent, timelineSet) => {
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 =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
if (typeof body !== 'string') return null;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<Text
as="div"
style={{
color: color.Success.Main,
fontStyle: 'italic',
whiteSpace: customBody ? 'initial' : 'pre-wrap',
wordBreak: 'break-word',
}}
priority="400"
>
<b>{`${senderDisplayName} `}</b>
{renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
{!!editedEvent && <MessageEditedContent />}
</Text>
<>
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${senderDisplayName} `}</b>
{renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
{!!editedEvent && <MessageEditedContent />}
</MessageTextBody>
{urls && urls.length > 0 && (
<UrlPreviewHolder>
{urls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
))}
</UrlPreviewHolder>
)}
</>
);
},
renderNotice: (mEventId, mEvent, timelineSet) => {
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 trimmedBody = trimReplyFromBody(body);
const urlsMatch = showUrlPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<Text
as="div"
style={{
whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap',
wordBreak: 'break-word',
}}
priority="300"
>
{renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
{!!editedEvent && <MessageEditedContent />}
</Text>
<>
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody(trimmedBody, typeof customBody === 'string' ? customBody : undefined)}
{!!editedEvent && <MessageEditedContent />}
</MessageTextBody>
{urls && urls.length > 0 && (
<UrlPreviewHolder>
{urls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={mEvent.getTs()} />
))}
</UrlPreviewHolder>
)}
</>
);
},
renderImage: (mEventId, mEvent) => {
@@ -1630,7 +1679,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;

View File

@@ -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);

View File

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

View File

@@ -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();

View File

@@ -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();
}

View File

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

View File

@@ -0,0 +1,183 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
UrlPreview,
UrlPreviewContent,
UrlPreviewDescription,
UrlPreviewImg,
} from '../../../components/url-preview';
import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../../hooks/useIntersectionObserver';
import * as css from './styles.css';
const linkStyles = { color: color.Success.Main };
export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
({ url, ts, ...props }, ref) => {
const mx = useMatrixClient();
const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
);
if (previewStatus.status === AsyncStatus.Idle) loadPreview();
if (previewStatus.status === AsyncStatus.Error) return null;
const renderContent = (prev: IPreviewUrlResponse) => {
const imgUrl = mx.mxcUrlToHttp(prev['og:image'] || '', 256, 256, 'scale', false);
return (
<>
{imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="no-referrer"
size="T200"
priority="300"
>
{typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
{decodeURIComponent(url)}
</Text>
<Text truncate priority="400">
<b>{prev['og:title']}</b>
</Text>
<Text size="T200" priority="300">
<UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
</Text>
</UrlPreviewContent>
</>
);
};
return (
<UrlPreview {...props} ref={ref}>
{previewStatus.status === AsyncStatus.Success ? (
renderContent(previewStatus.data)
) : (
<Box grow="Yes" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="400" />
</Box>
)}
</UrlPreview>
);
}
);
export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
const scrollRef = useRef<HTMLDivElement>(null);
const backAnchorRef = useRef<HTMLDivElement>(null);
const frontAnchorRef = useRef<HTMLDivElement>(null);
const [backVisible, setBackVisible] = useState(true);
const [frontVisible, setFrontVisible] = useState(true);
const intersectionObserver = useIntersectionObserver(
useCallback((entries) => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
if (backEntry) {
setBackVisible(backEntry.isIntersecting);
}
if (frontEntry) {
setFrontVisible(frontEntry.isIntersecting);
}
}, []),
useCallback(
() => ({
root: scrollRef.current,
rootMargin: '10px',
}),
[]
)
);
useEffect(() => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
if (backAnchor) intersectionObserver?.observe(backAnchor);
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
return () => {
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
};
}, [intersectionObserver]);
const handleScrollBack = () => {
const scroll = scrollRef.current;
if (!scroll) return;
const { offsetWidth, scrollLeft } = scroll;
scroll.scrollTo({
left: scrollLeft - offsetWidth / 1.3,
behavior: 'smooth',
});
};
const handleScrollFront = () => {
const scroll = scrollRef.current;
if (!scroll) return;
const { offsetWidth, scrollLeft } = scroll;
scroll.scrollTo({
left: scrollLeft + offsetWidth / 1.3,
behavior: 'smooth',
});
};
return (
<Box
direction="Column"
{...props}
ref={ref}
style={{ marginTop: config.space.S200, position: 'relative' }}
>
<Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
<Box shrink="No" alignItems="Center">
<div ref={backAnchorRef} />
{!backVisible && (
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
variant="Secondary"
radii="Pill"
size="300"
outlined
onClick={handleScrollBack}
>
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</>
)}
<Box alignItems="Inherit" gap="200">
{children}
{!frontVisible && (
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
variant="Primary"
radii="Pill"
size="300"
outlined
onClick={handleScrollFront}
>
<Icon size="300" src={Icons.ArrowRight} />
</IconButton>
</>
)}
<div ref={frontAnchorRef} />
</Box>
</Box>
</Scroll>
</Box>
);
});

View File

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

View File

@@ -1,5 +1,6 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config, toRem } from 'folds';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const RelativeBase = style([
DefaultReset,
@@ -56,6 +57,10 @@ export const MessageOptionsBar = style([
},
]);
export const MessageAvatar = style({
cursor: 'pointer',
});
export const MessageQuickReaction = style({
minWidth: toRem(32),
});
@@ -75,3 +80,52 @@ export const ReactionsContainer = style({
},
},
});
export const ReactionsTooltipText = style({
wordBreak: 'break-word',
});
export const UrlPreviewHolderGradient = recipe({
base: [
DefaultReset,
{
position: 'absolute',
height: '100%',
width: toRem(10),
zIndex: 1,
},
],
variants: {
position: {
Left: {
left: 0,
background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
},
Right: {
right: 0,
background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
},
},
},
});
export const UrlPreviewHolderBtn = recipe({
base: [
DefaultReset,
{
position: 'absolute',
zIndex: 1,
},
],
variants: {
position: {
Left: {
left: 0,
transform: 'translateX(-25%)',
},
Right: {
right: 0,
transform: 'translateX(25%)',
},
},
},
});

View File

@@ -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) {

View File

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

View File

@@ -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,11 +54,13 @@ 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');
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const spacings = ['0', '100', '200', '300', '400', '500']
@@ -94,14 +98,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 +151,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"
@@ -189,6 +193,26 @@ function AppearanceSection() {
)}
content={<Text variant="b3">Prevent images and videos from auto loading to save bandwidth.</Text>}
/>
<SettingTile
title="Url Preview"
options={(
<Toggle
isActive={urlPreview}
onToggle={() => setUrlPreview(!urlPreview)}
/>
)}
content={<Text variant="b3">Show url preview for link in messages.</Text>}
/>
<SettingTile
title="Url Preview in Encrypted Room"
options={(
<Toggle
isActive={encUrlPreview}
onToggle={() => setEncUrlPreview(!encUrlPreview)}
/>
)}
content={<Text variant="b3">Show url preview for link in encrypted messages.</Text>}
/>
<SettingTile
title="Show hidden events"
options={(
@@ -337,6 +361,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>

368
src/app/plugins/markdown.ts Normal file
View File

@@ -0,0 +1,368 @@
export type MatchResult = RegExpMatchArray | RegExpExecArray;
export type RuleMatch = (text: string) => MatchResult | null;
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 const replaceMatch = <C>(
convertPart: (txt: string) => Array<string | C>,
text: string,
match: MatchResult,
content: C
): Array<string | C> => [
...convertPart(beforeMatch(text, match)),
content,
...convertPart(afterMatch(text, match)),
];
/*
*****************
* 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,
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(
`${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 [, , 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(
`${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 [, , 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(
`${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 [, , g2] = match;
return `<i data-md="${ITALIC_MD_2}">${parse(g2)}</i>`;
},
};
const UNDERLINE_MD_1 = '__';
const UNDERLINE_PREFIX_1 = '_{2}';
const UNDERLINE_NEG_LA_1 = '(?!_)';
const UNDERLINE_REG_1 = new RegExp(
`${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}`
);
const UnderlineRule: InlineMDRule = {
match: (text) => text.match(UNDERLINE_REG_1),
html: (parse, match) => {
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(
`${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 [, , 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(`${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 [, , g2] = match;
return `<code data-md="${CODE_MD_1}">${g2}</code>`;
},
};
const SPOILER_MD_1 = '||';
const SPOILER_PREFIX_1 = '\\|{2}';
const SPOILER_NEG_LA_1 = '(?!\\|)';
const SPOILER_REG_1 = new RegExp(
`${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}`
);
const SpoilerRule: InlineMDRule = {
match: (text) => text.match(SPOILER_REG_1),
html: (parse, match) => {
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: InlineMDRule = {
match: (text) => text.match(LINK_REG_1),
html: (parse, match) => {
const [, g1, g2] = match;
return `<a data-md href="${g2}">${parse(g1)}</a>`;
},
};
const runInlineRule: InlineRuleRunner = (parse, text, rule) => {
const matchResult = rule.match(text);
if (matchResult) {
const content = rule.html(parse, matchResult);
return replaceMatch((txt) => [parse(txt)], text, matchResult, content).join('');
}
return undefined;
};
/**
* Runs multiple rules at the same time to better handle nested rules.
* Rules will be run in the order they appear.
*/
const runInlineRules: InlineRulesRunner = (parse, text, rules) => {
const matchResults = rules.map((rule) => rule.match(text));
let targetRule: InlineMDRule | undefined;
let targetResult: MatchResult | undefined;
for (let i = 0; i < matchResults.length; i += 1) {
const currentResult = matchResults[i];
if (currentResult && typeof currentResult.index === 'number') {
if (
!targetResult ||
(typeof targetResult?.index === 'number' && currentResult.index < targetResult.index)
) {
targetResult = currentResult;
targetRule = rules[i];
}
}
}
if (targetRule && targetResult) {
const content = targetRule.html(parse, targetResult);
return replaceMatch((txt) => [parse(txt)], text, targetResult, content).join('');
}
return undefined;
};
const LeveledRules = [
BoldRule,
ItalicRule1,
UnderlineRule,
ItalicRule2,
StrikeRule,
SpoilerRule,
LinkRule,
];
export const parseInlineMD: InlineMDParser = (text) => {
if (text === '') return text;
let result: string | undefined;
if (!result) result = runInlineRule(parseInlineMD, text, CodeRule);
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;
};

View File

@@ -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 { findAndReplace } from '../utils/findAndReplace';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
export const LINKIFY_OPTS: LinkifyOpts = {
attributes: {
target: '_blank',
@@ -27,6 +32,30 @@ export const LINKIFY_OPTS: LinkifyOpts = {
validate: {
url: (value) => /^(https|http|ftp|mailto|magnet)?:/.test(value),
},
ignoreTags: ['span'],
};
const textToEmojifyJSX = (text: string): (string | JSX.Element)[] =>
findAndReplace(
text,
EMOJI_REG_G,
(match, pushIndex) => (
<span key={pushIndex} className={css.EmoticonBase}>
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
{match[0]}
</span>
</span>
),
(txt) => txt
);
export const emojifyAndLinkify = (text: string, linkify?: boolean) => {
const emojifyJSX = textToEmojifyJSX(text);
if (linkify) {
return <Linkify options={LINKIFY_OPTS}>{emojifyJSX}</Linkify>;
}
return emojifyJSX;
};
export const getReactCustomHtmlParser = (
@@ -45,7 +74,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 +82,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 +90,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 +98,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 +106,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 +114,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 +122,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 +130,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 +146,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 +154,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 +173,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 +271,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;
},

View File

@@ -9,7 +9,7 @@ export interface Settings {
useSystemTheme: boolean;
isMarkdown: boolean;
editorToolbar: boolean;
useSystemEmoji: boolean;
twitterEmoji: boolean;
isPeopleDrawer: boolean;
memberSortFilterIndex: number;
@@ -19,6 +19,8 @@ export interface Settings {
hideMembershipEvents: boolean;
hideNickAvatarEvents: boolean;
mediaAutoLoad: boolean;
urlPreview: boolean;
encUrlPreview: boolean;
showHiddenEvents: boolean;
showNotifications: boolean;
@@ -28,9 +30,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,
@@ -40,6 +42,8 @@ const defaultSettings: Settings = {
hideMembershipEvents: false,
hideNickAvatarEvents: true,
mediaAutoLoad: true,
urlPreview: true,
encUrlPreview: false,
showHiddenEvents: false,
showNotifications: true,

View File

@@ -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,
},
],

View File

@@ -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;

View File

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

View File

@@ -1,191 +0,0 @@
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 type MatchReplacer = (
parse: PlainMDParser,
text: string,
match: MatchResult,
content: string
) => string;
export type RuleRunner = (parse: PlainMDParser, text: string, rule: MDRule) => string | undefined;
export type RulesRunner = (
parse: PlainMDParser,
text: string,
rules: MDRule[]
) => string | undefined;
const MIN_ANY = '(.+?)';
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 = {
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 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 = {
match: (text) => text.match(ITALIC_REG_1),
html: (parse, match) => {
const [, g1] = match;
return `<i data-md="${ITALIC_MD_1}">${parse(g1)}</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 = {
match: (text) => text.match(ITALIC_REG_2),
html: (parse, match) => {
const [, g1] = match;
return `<i data-md="${ITALIC_MD_2}">${parse(g1)}</i>`;
},
};
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}`
);
const UnderlineRule: MDRule = {
match: (text) => text.match(UNDERLINE_REG_1),
html: (parse, match) => {
const [, g1] = match;
return `<u data-md="${UNDERLINE_MD_1}">${parse(g1)}</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 = {
match: (text) => text.match(STRIKE_REG_1),
html: (parse, match) => {
const [, g1] = match;
return `<del data-md="${STRIKE_MD_1}">${parse(g1)}</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 = {
match: (text) => text.match(CODE_REG_1),
html: (parse, match) => {
const [, g1] = match;
return `<code data-md="${CODE_MD_1}">${g1}</code>`;
},
};
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}`
);
const SpoilerRule: MDRule = {
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 LINK_ALT = `\\[${MIN_ANY}\\]`;
const LINK_URL = `\\((https?:\\/\\/.+?)\\)`;
const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`);
const LinkRule: MDRule = {
match: (text) => text.match(LINK_REG_1),
html: (parse, match) => {
const [, g1, g2] = match;
return `<a data-md href="${g2}">${parse(g1)}</a>`;
},
};
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 matchResult = rule.match(text);
if (matchResult) {
const content = rule.html(parse, matchResult);
return replaceMatch(parse, text, matchResult, content);
}
return undefined;
};
/**
* 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 matchResults = rules.map((rule) => rule.match(text));
let targetRule: MDRule | undefined;
let targetResult: MatchResult | undefined;
for (let i = 0; i < matchResults.length; i += 1) {
const currentResult = matchResults[i];
if (currentResult && typeof currentResult.index === 'number') {
if (
!targetResult ||
(typeof targetResult?.index === 'number' && currentResult.index < targetResult.index)
) {
targetResult = currentResult;
targetRule = rules[i];
}
}
}
if (targetRule && targetResult) {
const content = targetRule.html(parse, targetResult);
return replaceMatch(parse, text, targetResult, content);
}
return undefined;
};
const LeveledRules = [
BoldRule,
ItalicRule1,
UnderlineRule,
ItalicRule2,
StrikeRule,
SpoilerRule,
LinkRule,
];
export const parseInlineMD = (text: string): string => {
let result: string | undefined;
if (!result) result = runRule(parseInlineMD, text, CodeRule);
if (!result) result = runRules(parseInlineMD, text, LeveledRules);
return result ?? text;
};

9
src/app/utils/regex.ts Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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;

View File

@@ -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'],

View File

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

View File

@@ -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;
}