Compare commits

..

14 commits

Author SHA1 Message Date
hippoz
66dbb1616d
quick fix 2023-03-26 01:55:46 +02:00
hippoz
212f18b69c
fix space manage 2023-03-26 01:06:03 +02:00
hippoz
197b15eb6d
improve bundle size and speed by using code splitting 2023-03-23 19:50:24 +02:00
hippoz
aec2c44c29
speed up application and reduce bundle size by switching to preact 2023-03-23 15:41:58 +02:00
hippoz
291dad7ef8
change user id colors to gruvbox material dark 2023-03-23 02:17:06 +02:00
hippoz
d193645b11
change scrollbar behavior to be more compatible 2023-03-23 02:00:09 +02:00
hippoz
a7a4b5b0f1
fix crash on permissions tab on non-standard matrix servers 2023-03-23 01:56:14 +02:00
hippoz
745cd87b7f
fix crashes and re-add twemoji 2023-03-23 01:48:08 +02:00
hippoz
b86967fc3c
increase room selector padding 2023-03-20 02:20:03 +02:00
hippoz
870de09b0e
improve profile viewer 2023-03-20 02:16:55 +02:00
hippoz
7dcdf98b4e
fix segmented controls 2023-03-20 02:12:18 +02:00
hippoz
34f4afab67
improve heading font sizes 2023-03-20 02:10:43 +02:00
hippoz
0d0ea8f09f
add readme clarification 2023-03-20 00:17:27 +02:00
hippoz
5bc5e3cbd2
visual improvements, remove various dependencies 2023-03-20 00:06:42 +02:00
287 changed files with 3966 additions and 28751 deletions

View file

@ -20,9 +20,6 @@ module.exports = {
ecmaVersion: 'latest',
sourceType: 'module',
},
"globals": {
JSX: "readonly"
},
plugins: [
'react',
'@typescript-eslint'
@ -30,7 +27,6 @@ module.exports = {
rules: {
'linebreak-style': 0,
'no-underscore-dangle': 0,
"no-shadow": "off",
"import/prefer-default-export": "off",
"import/extensions": "off",
@ -59,6 +55,5 @@ module.exports = {
"react-hooks/exhaustive-deps": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-shadow": "error"
},
};

View file

@ -12,20 +12,18 @@ jobs:
PR_NUMBER: ${{github.event.number}}
steps:
- name: Checkout repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.2.0
- name: Setup node
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v3.5.1
with:
node-version: 18.12.1
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: "--max_old_space_size=4096"
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v3.1.2
uses: actions/upload-artifact@v3.1.1
with:
name: preview
path: dist
@ -33,7 +31,7 @@ jobs:
- name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number
uses: actions/upload-artifact@v3.1.2
uses: actions/upload-artifact@v3.1.1
with:
name: pr
path: ./pr.txt

View file

@ -12,7 +12,7 @@ jobs:
- name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: cla-assistant/github-action@v2.3.0
uses: cla-assistant/github-action@v2.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret

View file

@ -15,7 +15,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download pr number
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615
uses: dawidd6/action-download-artifact@e6e25ac3a2b93187502a8be1ef9e9603afc34925
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
@ -24,7 +24,7 @@ jobs:
id: pr
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615
uses: dawidd6/action-download-artifact@e6e25ac3a2b93187502a8be1ef9e9603afc34925
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
@ -32,7 +32,7 @@ jobs:
path: dist
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352
uses: nwtgck/actions-netlify@5da65c9f74c7961c5501a3ba329b8d0912f39c03
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@1d3973dc4b8e1399c0620d3f2b1aa5e795465308
uses: thollander/actions-comment-pull-request@c22fb302208b7b170d252a61a505d2ea27245eff
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View file

@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.2.0
- name: Build Docker image
uses: docker/build-push-action@v4.1.1
uses: docker/build-push-action@v3.2.0
with:
context: .
push: false

View file

@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.2.0
- name: NPM Lockfile Changes
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
with:

View file

@ -11,20 +11,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.2.0
- name: Setup node
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v3.5.1
with:
node-version: 18.12.1
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: "--max_old_space_size=4096"
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352
uses: nwtgck/actions-netlify@5da65c9f74c7961c5501a3ba329b8d0912f39c03
with:
publish-dir: dist
deploy-message: "Dev deploy ${{ github.sha }}"

View file

@ -10,20 +10,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.2.0
- name: Setup node
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v3.5.1
with:
node-version: 18.12.1
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: "--max_old_space_size=4096"
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352
uses: nwtgck/actions-netlify@5da65c9f74c7961c5501a3ba329b8d0912f39c03
with:
publish-dir: dist
deploy-message: "Prod deploy ${{ github.ref_name }}"
@ -66,31 +64,31 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v3.2.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.2.0
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.7.0
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Container registry
uses: docker/login-action@v2.2.0
uses: docker/login-action@v2.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4.6.0
uses: docker/metadata-action@v4.1.1
with:
images: |
${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v4.1.1
uses: docker/build-push-action@v3.2.0
with:
context: .
platforms: linux/amd64,linux/arm64

View file

@ -6,12 +6,11 @@ WORKDIR /src
COPY .npmrc package.json package-lock.json /src/
RUN npm ci
COPY . /src/
ENV NODE_OPTIONS=--max_old_space_size=4096
RUN npm run build
## App
FROM nginx:1.25.1-alpine
FROM nginx:1.23.3-alpine
COPY --from=builder /src/dist /app

View file

@ -12,8 +12,8 @@
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
</p>
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch.
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch. This is a fork of the original project, located [here](https://github.com/cinnyapp/cinny). This fork aims to solve various annoyances and add new features. We're planning on upstreaming these changes once they're stable enough. We're not affiliated with the official Cinny project in any way.
- [Roadmap](https://github.com/ajbura/cinny/projects/11)
- [Contributing](./CONTRIBUTING.md)
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">

View file

@ -1,12 +1,12 @@
{
"defaultHomeserver": 2,
"defaultHomeserver": 0,
"homeserverList": [
"http://localhost:3000",
"converser.eu",
"envs.net",
"halogen.city",
"matrix.org",
"monero.social",
"mozilla.org",
"xmr.se"
"mozilla.org"
],
"allowCustomHomeservers": true
}

View file

@ -1,101 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cinny</title>
<meta name="name" content="Cinny" />
<meta name="author" content="Ajay Bura" />
<meta
name="description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
/>
<meta
name="keywords"
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
/>
<meta property="og:title" content="Cinny" />
<meta property="og:url" content="https://cinny.in" />
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
<meta
property="og:description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
/>
<meta name="theme-color" content="#000000" />
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cinny</title>
<meta name="name" content="Cinny" />
<meta name="author" content="Ajay Bura" />
<meta name="description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source." />
<meta name="keywords" content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element" />
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<meta property="og:title" content="Cinny" />
<meta property="og:description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source." />
<meta name="theme-color" content="#000000" />
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Cinny" />
<meta name="apple-mobile-web-app-title" content="Cinny" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="57x57"
href="./public/res/apple/apple-touch-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="./public/res/apple/apple-touch-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="./public/res/apple/apple-touch-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="./public/res/apple/apple-touch-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="./public/res/apple/apple-touch-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="./public/res/apple/apple-touch-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="./public/res/apple/apple-touch-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="./public/res/apple/apple-touch-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="167x167"
href="./public/res/apple/apple-touch-icon-167x167.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="./public/res/apple/apple-touch-icon-180x180.png"
/>
</head>
<body id="appBody">
<script>
window.global ||= window;
</script>
<div id="root"></div>
<audio id="notificationSound">
<source src="./public/sound/notification.ogg" type="audio/ogg" />
</audio>
<audio id="inviteSound">
<source src="./public/sound/invite.ogg" type="audio/ogg" />
</audio>
<script type="module" src="./src/index.jsx"></script>
</body>
</html>
<link rel="manifest" href="./manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Cinny" />
<meta name="apple-mobile-web-app-title" content="Cinny" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" sizes="57x57" href="./public/res/apple/apple-touch-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="60x60" href="./public/res/apple/apple-touch-icon-60x60.png" />
<link rel="apple-touch-icon" sizes="72x72" href="./public/res/apple/apple-touch-icon-72x72.png" />
<link rel="apple-touch-icon" sizes="76x76" href="./public/res/apple/apple-touch-icon-76x76.png" />
<link rel="apple-touch-icon" sizes="114x114" href="./public/res/apple/apple-touch-icon-114x114.png" />
<link rel="apple-touch-icon" sizes="120x120" href="./public/res/apple/apple-touch-icon-120x120.png" />
<link rel="apple-touch-icon" sizes="144x144" href="./public/res/apple/apple-touch-icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="./public/res/apple/apple-touch-icon-152x152.png" />
<link rel="apple-touch-icon" sizes="167x167" href="./public/res/apple/apple-touch-icon-167x167.png" />
<link rel="apple-touch-icon" sizes="180x180" href="./public/res/apple/apple-touch-icon-180x180.png" />
</head>
<body id="appBody">
<script>
window.global ||= window;
</script>
<div id="root"></div>
<audio id="notificationSound">
<source src="./public/sound/notification.ogg" type="audio/ogg" />
</audio>
<audio id="inviteSound">
<source src="./public/sound/invite.ogg" type="audio/ogg" />
</audio>
<script type="module" src="./src/index.jsx"></script>
</body>
</html>

8026
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "3.2.0",
"version": "2.2.4",
"description": "Yet another matrix client",
"main": "index.js",
"engines": {
@ -9,6 +9,7 @@
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*",
"check:prettier": "prettier --check .",
@ -19,85 +20,54 @@
"author": "Ajay Bura",
"license": "AGPL-3.0-only",
"dependencies": {
"@fontsource/inter": "4.5.14",
"@fontsource/inter": "4.5.15",
"@fontsource/roboto": "4.5.8",
"@khanacademy/simple-markdown": "0.8.6",
"@matrix-org/olm": "3.2.14",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tippyjs/react": "4.2.6",
"@vanilla-extract/css": "1.9.3",
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0",
"blurhash": "2.0.4",
"blurhash": "2.0.5",
"browser-encrypt-attachment": "0.3.0",
"classnames": "2.3.2",
"dateformat": "5.0.3",
"dayjs": "1.11.10",
"domhandler": "5.0.3",
"emojibase": "6.1.0",
"emojibase-data": "7.0.1",
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "1.5.0",
"flux": "4.0.4",
"formik": "2.2.9",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "1.12.0",
"katex": "0.16.4",
"linkify-html": "4.0.2",
"linkify-react": "4.1.1",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "24.1.0",
"millify": "6.1.0",
"prismjs": "1.29.0",
"html-react-parser": "3.0.13",
"linkify-html": "4.1.0",
"linkifyjs": "4.1.0",
"matrix-js-sdk": "23.5.0",
"preact": "10.13.1",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0",
"react-dnd": "15.1.2",
"react-dnd-html5-backend": "15.1.3",
"react-dom": "17.0.2",
"react-error-boundary": "4.0.10",
"react-blurhash": "0.3.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-modal": "3.16.1",
"react-range": "1.8.14",
"sanitize-html": "2.8.0",
"slate": "0.94.1",
"slate-history": "0.93.0",
"slate-react": "0.98.4",
"sanitize-html": "2.10.0",
"tippy.js": "6.3.7",
"twemoji": "14.0.2"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@preact/preset-vite": "2.5.0",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
"@types/file-saver": "2.0.5",
"@types/node": "18.11.18",
"@types/prismjs": "1.26.0",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"@types/sanitize-html": "2.9.0",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
"@vitejs/plugin-react": "3.0.0",
"@rollup/plugin-wasm": "6.1.2",
"@types/node": "18.15.5",
"@typescript-eslint/eslint-plugin": "5.56.0",
"@typescript-eslint/parser": "5.56.0",
"buffer": "6.0.3",
"eslint": "8.29.0",
"eslint": "8.36.0",
"eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-react": "7.31.11",
"eslint-config-prettier": "8.8.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"mini-svg-data-uri": "1.4.4",
"prettier": "2.8.1",
"sass": "1.56.2",
"typescript": "4.9.4",
"vite": "4.3.9",
"vite-plugin-static-copy": "0.13.0"
"prettier": "2.8.6",
"sass": "1.59.3",
"typescript": "5.0.2",
"vite": "4.2.1",
"vite-plugin-static-copy": "0.13.1"
}
}
}

View file

@ -40,7 +40,7 @@ const Avatar = React.forwardRef(({
iconSrc !== null
? <RawIcon size={size} src={iconSrc} color={iconColor} />
: text !== null && (
<Text variant={textSize} primary>
<Text variant={textSize} monospace>
{twemojify(avatarInitials(text))}
</Text>
)

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import './ContextMenu.scss';
@ -13,8 +13,12 @@ function ContextMenu({
content, placement, maxWidth, render, afterToggle,
}) {
const [isVisible, setVisibility] = useState(false);
const showMenu = () => setVisibility(true);
const hideMenu = () => setVisibility(false);
const showMenu = useCallback(() => {
setVisibility(true);
});
const hideMenu = useCallback(() => {
setVisibility(false);
});
useEffect(() => {
if (afterToggle !== null) afterToggle(isVisible);
@ -31,7 +35,7 @@ function ContextMenu({
interactive
arrow={false}
maxWidth={maxWidth}
duration={125}
duration={150}
>
{render(isVisible ? hideMenu : showMenu)}
</Tippy>

View file

@ -0,0 +1,6 @@
import Text from '../text/Text';
import './LoadingText.scss';
export function LoadingText() {
return <Text className='loading-text'>Loading...</Text>
}

View file

@ -0,0 +1,5 @@
.loading-text {
display: block;
padding: var(--sp-tight);
color: var(--tc-surface-low);
}

View file

@ -1,33 +0,0 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './Math.scss';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import 'katex/dist/contrib/copy-tex';
const Math = React.memo(({
content, throwOnError, errorColor, displayMode,
}) => {
const ref = useRef(null);
useEffect(() => {
katex.render(content, ref.current, { throwOnError, errorColor, displayMode });
}, [content, throwOnError, errorColor, displayMode]);
return <span ref={ref} />;
});
Math.defaultProps = {
throwOnError: null,
errorColor: null,
displayMode: null,
};
Math.propTypes = {
content: PropTypes.string.isRequired,
throwOnError: PropTypes.bool,
errorColor: PropTypes.string,
displayMode: PropTypes.bool,
};
export default Math;

View file

@ -1,3 +0,0 @@
.katex-display {
margin: 0 !important;
}

View file

@ -39,13 +39,12 @@
}
.ReactModal__Overlay {
animation: raw-modal--overlay 190ms;
animation: raw-modal--overlay 210ms;
animation-timing-function: cubic-bezier(0.77,0,0.18,1);
contain: strict;
}
.ReactModal__Content {
animation: raw-modal--content 190ms;
animation: raw-modal--content 210ms;
animation-timing-function: cubic-bezier(0.77,0,0.18,1);
}

View file

@ -41,10 +41,10 @@
}
@mixin scroll__h {
overflow-x: scroll;
overflow-x: auto;
}
@mixin scroll__v {
overflow-y: scroll;
overflow-y: auto;
}
@mixin scroll--auto-hide {
@extend .firefox-scrollbar--transparent;

View file

@ -4,13 +4,14 @@ import './Text.scss';
function Text({
className, style, variant, weight,
primary, span, children,
primary, monospace, span, children,
}) {
const classes = [];
if (className) classes.push(className);
classes.push(`text text-${variant} text-${weight}`);
if (primary) classes.push('font-primary');
if (monospace) classes.push('font-monospace');
const textClass = classes.join(' ');
if (span) return <span className={textClass} style={style}>{ children }</span>;
@ -26,6 +27,7 @@ Text.defaultProps = {
variant: 'b1',
weight: 'normal',
primary: false,
monospace: false,
span: false,
};
@ -35,6 +37,7 @@ Text.propTypes = {
variant: PropTypes.oneOf(['h1', 'h2', 's1', 'b1', 'b2', 'b3']),
weight: PropTypes.oneOf(['light', 'normal', 'medium', 'bold']),
primary: PropTypes.bool,
monospace: PropTypes.bool,
span: PropTypes.bool,
children: PropTypes.node.isRequired,
};

View file

@ -9,6 +9,10 @@
}
}
.emoji {
content-visibility: auto;
}
.text {
margin: 0;
padding: 0;

View file

@ -6,32 +6,24 @@ import { isInSameDay } from '../../../util/common';
function Time({ timestamp, fullTime }) {
const date = new Date(timestamp);
let formattedFullTime;
let formattedDate;
if (fullTime) {
formattedFullTime = dateFormat(date, 'dd mmmm yyyy, hh:MM TT');
formattedDate = formattedFullTime;
formattedDate = formattedFullTime = dateFormat(date, 'dd mmmm yyyy, hh:MM TT')
} else {
const compareDate = new Date();
const isToday = isInSameDay(date, compareDate);
compareDate.setDate(compareDate.getDate() - 1);
const isYesterday = isInSameDay(date, compareDate);
formattedDate = dateFormat(date, isToday || isYesterday ? 'hh:MM TT' : 'dd/mm/yyyy');
formattedDate = dateFormat(date, isToday || isYesterday ? 'hh:MM TT' : 'dd/mm/yyyy hh:MM TT');
if (isYesterday) {
formattedDate = `Yesterday, ${formattedDate}`;
}
}
return (
<time
dateTime={date.toISOString()}
title={formattedFullTime}
>
{formattedDate}
</time>
<time dateTime={date.toISOString()}>{formattedDate}</time>
);
}

View file

@ -4,7 +4,7 @@ import './Tooltip.scss';
import Tippy from '@tippyjs/react';
function Tooltip({
className, placement, content, delay, children,
className, placement, content, children,
}) {
return (
<Tippy
@ -14,8 +14,8 @@ function Tooltip({
arrow={false}
maxWidth={250}
placement={placement}
delay={delay}
duration={[100, 0]}
duration={[75, 0]}
animation="scale-subtle"
>
{children}
</Tippy>
@ -25,14 +25,12 @@ function Tooltip({
Tooltip.defaultProps = {
placement: 'top',
className: '',
delay: [200, 0],
};
Tooltip.propTypes = {
className: PropTypes.string,
placement: PropTypes.string,
content: PropTypes.node.isRequired,
delay: PropTypes.arrayOf(PropTypes.number),
children: PropTypes.node.isRequired,
};

View file

@ -1,9 +0,0 @@
import { Dispatch, ReactElement, SetStateAction, useState } from 'react';
type UseStateProviderProps<T> = {
initial: T | (() => T);
children: (value: T, setter: Dispatch<SetStateAction<T>>) => ReactElement;
};
export function UseStateProvider<T>({ initial, children }: UseStateProviderProps<T>) {
return children(...useState(initial));
}

View file

@ -1,72 +0,0 @@
import { style } from '@vanilla-extract/css';
import { color, config, DefaultReset, toRem } from 'folds';
export const Editor = style([
DefaultReset,
{
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R400,
overflow: 'hidden',
},
]);
export const EditorOptions = style([
DefaultReset,
{
padding: config.space.S200,
},
]);
export const EditorTextareaScroll = style({});
export const EditorTextarea = style([
DefaultReset,
{
flexGrow: 1,
height: '100%',
padding: `${toRem(13)} ${toRem(1)}`,
selectors: {
[`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13),
},
[`${EditorTextareaScroll}:last-child &`]: {
paddingRight: toRem(13),
},
'&:focus': {
outline: 'none',
},
},
},
]);
export const EditorPlaceholder = style([
DefaultReset,
{
position: 'absolute',
zIndex: 1,
width: '100%',
opacity: config.opacity.Placeholder,
pointerEvents: 'none',
userSelect: 'none',
selectors: {
'&:not(:first-child)': {
display: 'none',
},
},
},
]);
export const EditorToolbarBase = style({
padding: `0 ${config.borderWidth.B300}`,
});
export const EditorToolbar = style({
padding: config.space.S100,
});
export const MarkdownBtnBox = style({
paddingRight: config.space.S100,
});

View file

@ -1,82 +0,0 @@
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
config,
Icon,
IconButton,
Icons,
Line,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import { CustomEditor, useEditor } from './Editor';
import { Toolbar } from './Toolbar';
export function EditorPreview() {
const [open, setOpen] = useState(false);
const editor = useEditor();
const [toolbar, setToolbar] = useState(false);
return (
<>
<IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
<Icon src={Icons.BlockQuote} />
</IconButton>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
}}
>
<Modal size="500">
<div style={{ padding: config.space.S400 }}>
<CustomEditor
editor={editor}
placeholder="Send a message..."
before={
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={() => setToolbar(!toolbar)}
aria-pressed={toolbar}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Smile} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>
</>
}
bottom={
toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)
}
/>
</div>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</>
);
}

View file

@ -1,167 +0,0 @@
/* eslint-disable no-param-reassign */
import React, {
ClipboardEventHandler,
KeyboardEventHandler,
ReactNode,
forwardRef,
useCallback,
useState,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor } from 'slate';
import {
Slate,
Editable,
withReact,
RenderLeafProps,
RenderElementProps,
RenderPlaceholderProps,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];
const withInline = (editor: Editor): Editor => {
const { isInline } = editor;
editor.isInline = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
element.type
) || isInline(element);
return editor;
};
const withVoid = (editor: Editor): Editor => {
const { isVoid } = editor;
editor.isVoid = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
isVoid(element);
return editor;
};
export const useEditor = (): Editor => {
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
return editor;
};
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
editor: Editor;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler;
onPaste?: ClipboardEventHandler;
};
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
editableName,
top,
bottom,
before,
after,
maxHeight = '50vh',
editor,
placeholder,
onKeyDown,
onKeyUp,
onChange,
onPaste,
},
ref
) => {
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
);
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
onKeyDown?.(evt);
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
if (shortcutToggled) evt.preventDefault();
},
[editor, onKeyDown]
);
const renderPlaceholder = useCallback(({ attributes, children }: RenderPlaceholderProps) => {
// drop style attribute as we use our custom placeholder css.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { style, ...props } = attributes;
return (
<Text
as="span"
{...props}
className={css.EditorPlaceholder}
contentEditable={false}
truncate
>
{children}
</Text>
);
}, []);
return (
<div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before}
</Box>
)}
<Scroll
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={{ maxHeight }}
size="300"
visibility="Hover"
hideTrack
>
<Editable
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}
renderElement={renderElement}
renderLeaf={renderLeaf}
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}
/>
</Scroll>
{after && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{after}
</Box>
)}
</Box>
{bottom}
</Slate>
</div>
);
}
);

View file

@ -1,273 +0,0 @@
import { Scroll, Text } from 'folds';
import React from 'react';
import {
RenderElementProps,
RenderLeafProps,
useFocused,
useSelected,
useSlate,
} from 'slate-react';
import * as css from '../../styles/CustomHtml.css';
import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getBeginCommand } from './utils';
import { BlockType } from './types';
// Put this at the start and end of an inline component to work around this Chromium bug:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
function InlineChromiumBugfix() {
return (
<span className={css.InlineChromiumBugfix} contentEditable={false}>
{String.fromCodePoint(160) /* Non-breaking space */}
</span>
);
}
function RenderMentionElement({
attributes,
element,
children,
}: { element: MentionElement } & RenderElementProps) {
const selected = useSelected();
const focused = useFocused();
return (
<span
{...attributes}
className={css.Mention({
highlight: element.highlight,
focus: selected && focused,
})}
contentEditable={false}
>
{element.name}
{children}
</span>
);
}
function RenderCommandElement({
attributes,
element,
children,
}: { element: CommandElement } & RenderElementProps) {
const selected = useSelected();
const focused = useFocused();
const editor = useSlate();
return (
<span
{...attributes}
className={css.Command({
focus: selected && focused,
active: getBeginCommand(editor) === element.command,
})}
contentEditable={false}
>
{`/${element.command}`}
{children}
</span>
);
}
function RenderEmoticonElement({
attributes,
element,
children,
}: { element: EmoticonElement } & RenderElementProps) {
const mx = useMatrixClient();
const selected = useSelected();
const focused = useFocused();
return (
<span className={css.EmoticonBase} {...attributes}>
<span
className={css.Emoticon({
focus: selected && focused,
})}
contentEditable={false}
>
{element.key.startsWith('mxc://') ? (
<img
className={css.EmoticonImg}
src={mx.mxcUrlToHttp(element.key) ?? element.key}
alt={element.shortcode}
/>
) : (
element.key
)}
{children}
</span>
</span>
);
}
function RenderLinkElement({
attributes,
element,
children,
}: { element: LinkElement } & RenderElementProps) {
return (
<a href={element.href} {...attributes}>
<InlineChromiumBugfix />
{children}
</a>
);
}
export function RenderElement({ attributes, element, children }: RenderElementProps) {
switch (element.type) {
case BlockType.Paragraph:
return (
<Text {...attributes} className={css.Paragraph}>
{children}
</Text>
);
case BlockType.Heading:
if (element.level === 1)
return (
<Text className={css.Heading} as="h2" size="H2" {...attributes}>
{children}
</Text>
);
if (element.level === 2)
return (
<Text className={css.Heading} as="h3" size="H3" {...attributes}>
{children}
</Text>
);
if (element.level === 3)
return (
<Text className={css.Heading} as="h4" size="H4" {...attributes}>
{children}
</Text>
);
return (
<Text className={css.Heading} as="h3" size="H3" {...attributes}>
{children}
</Text>
);
case BlockType.CodeLine:
return <div {...attributes}>{children}</div>;
case BlockType.CodeBlock:
return (
<Text as="pre" className={css.CodeBlock} {...attributes}>
<Scroll
direction="Horizontal"
variant="Secondary"
size="300"
visibility="Hover"
hideTrack
>
<div className={css.CodeBlockInternal}>{children}</div>
</Scroll>
</Text>
);
case BlockType.QuoteLine:
return <div {...attributes}>{children}</div>;
case BlockType.BlockQuote:
return (
<Text as="blockquote" className={css.BlockQuote} {...attributes}>
{children}
</Text>
);
case BlockType.ListItem:
return (
<Text as="li" {...attributes}>
{children}
</Text>
);
case BlockType.OrderedList:
return (
<ol className={css.List} {...attributes}>
{children}
</ol>
);
case BlockType.UnorderedList:
return (
<ul className={css.List} {...attributes}>
{children}
</ul>
);
case BlockType.Mention:
return (
<RenderMentionElement attributes={attributes} element={element}>
{children}
</RenderMentionElement>
);
case BlockType.Emoticon:
return (
<RenderEmoticonElement attributes={attributes} element={element}>
{children}
</RenderEmoticonElement>
);
case BlockType.Link:
return (
<RenderLinkElement attributes={attributes} element={element}>
{children}
</RenderLinkElement>
);
case BlockType.Command:
return (
<RenderCommandElement attributes={attributes} element={element}>
{children}
</RenderCommandElement>
);
default:
return (
<Text className={css.Paragraph} {...attributes}>
{children}
</Text>
);
}
}
export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
let child = children;
if (leaf.bold)
child = (
<strong {...attributes}>
<InlineChromiumBugfix />
{child}
</strong>
);
if (leaf.italic)
child = (
<i {...attributes}>
<InlineChromiumBugfix />
{child}
</i>
);
if (leaf.underline)
child = (
<u {...attributes}>
<InlineChromiumBugfix />
{child}
</u>
);
if (leaf.strikeThrough)
child = (
<s {...attributes}>
<InlineChromiumBugfix />
{child}
</s>
);
if (leaf.code)
child = (
<code className={css.Code} {...attributes}>
<InlineChromiumBugfix />
{child}
</code>
);
if (leaf.spoiler)
child = (
<span className={css.Spoiler()} {...attributes}>
<InlineChromiumBugfix />
{child}
</span>
);
if (child !== children) return child;
return <span {...attributes}>{child}</span>;
}

View file

@ -1,355 +0,0 @@
import FocusTrap from 'focus-trap-react';
import {
Badge,
Box,
config,
Icon,
IconButton,
Icons,
IconSrc,
Line,
Menu,
PopOut,
Scroll,
Text,
Tooltip,
TooltipProvider,
toRem,
} from 'folds';
import React, { ReactNode, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import {
headingLevel,
isAnyMarkActive,
isBlockActive,
isMarkActive,
removeAllMark,
toggleBlock,
toggleMark,
} from './utils';
import * as css from './Editor.css';
import { BlockType, MarkType } from './types';
import { HeadingLevel } from './slate';
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 (
<Tooltip style={{ padding: config.space.S300 }}>
<Box gap="200" direction="Column" alignItems="Center">
<Text align="Center">{text}</Text>
{shortCode && (
<Badge as="kbd" radii="300" size="500">
<Text size="T200" align="Center">
{shortCode}
</Text>
</Badge>
)}
</Box>
</Tooltip>
);
}
type MarkButtonProps = { format: MarkType; icon: IconSrc; tooltip: ReactNode };
export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
const editor = useSlate();
const disableInline = isBlockActive(editor, BlockType.CodeBlock);
if (disableInline) {
removeAllMark(editor);
}
const handleClick = () => {
toggleMark(editor, format);
ReactEditor.focus(editor);
};
return (
<TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="SurfaceVariant"
onClick={handleClick}
aria-pressed={isMarkActive(editor, format)}
size="400"
radii="300"
disabled={disableInline}
>
<Icon size="200" src={icon} />
</IconButton>
)}
</TooltipProvider>
);
}
type BlockButtonProps = {
format: BlockType;
icon: IconSrc;
tooltip: ReactNode;
};
export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
const editor = useSlate();
const handleClick = () => {
toggleBlock(editor, format, { level: 1 });
ReactEditor.focus(editor);
};
return (
<TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="SurfaceVariant"
onClick={handleClick}
aria-pressed={isBlockActive(editor, format)}
size="400"
radii="300"
>
<Icon size="200" src={icon} />
</IconButton>
)}
</TooltipProvider>
);
}
export function HeadingBlockButton() {
const editor = useSlate();
const level = headingLevel(editor);
const [open, setOpen] = useState(false);
const isActive = isBlockActive(editor, BlockType.Heading);
const modKey = 'Ctrl';
const handleMenuSelect = (selectedLevel: HeadingLevel) => {
setOpen(false);
toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
ReactEditor.focus(editor);
};
return (
<PopOut
open={open}
offset={5}
position="Top"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
}}
>
<Menu style={{ padding: config.space.S100 }}>
<Box gap="100">
<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>
}
>
{(ref) => (
<IconButton
style={{ width: 'unset' }}
ref={ref}
variant="SurfaceVariant"
onClick={() => (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))}
aria-pressed={isActive}
size="400"
radii="300"
>
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
</IconButton>
)}
</PopOut>
);
}
type ExitFormattingProps = { tooltip: ReactNode };
export function ExitFormatting({ tooltip }: ExitFormattingProps) {
const editor = useSlate();
const handleClick = () => {
if (isAnyMarkActive(editor)) {
removeAllMark(editor);
} else if (!isBlockActive(editor, BlockType.Paragraph)) {
toggleBlock(editor, BlockType.Paragraph);
}
ReactEditor.focus(editor);
};
return (
<TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="SurfaceVariant"
onClick={handleClick}
size="400"
radii="300"
>
<Text size="B400">{`Exit ${KeySymbol.Hyper}`}</Text>
</IconButton>
)}
</TooltipProvider>
);
}
export function Toolbar() {
const editor = useSlate();
const modKey = '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}>
<Scroll direction="Horizontal" size="0">
<Box className={css.EditorToolbar} alignItems="Center" gap="300">
<>
<Box shrink="No" gap="100">
<MarkButton
format={MarkType.Bold}
icon={Icons.Bold}
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`} />}
/>
<MarkButton
format={MarkType.Italic}
icon={Icons.Italic}
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`} />}
/>
<MarkButton
format={MarkType.Underline}
icon={Icons.Underline}
tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`} />}
/>
<MarkButton
format={MarkType.StrikeThrough}
icon={Icons.Strike}
tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`} />}
/>
<MarkButton
format={MarkType.Code}
icon={Icons.Code}
tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`} />}
/>
<MarkButton
format={MarkType.Spoiler}
icon={Icons.EyeBlind}
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`} />}
/>
</Box>
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
</>
<Box shrink="No" gap="100">
<BlockButton
format={BlockType.BlockQuote}
icon={Icons.BlockQuote}
tooltip={<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`} />}
/>
<BlockButton
format={BlockType.CodeBlock}
icon={Icons.BlockCode}
tooltip={<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`} />}
/>
<BlockButton
format={BlockType.OrderedList}
icon={Icons.OrderList}
tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`} />}
/>
<BlockButton
format={BlockType.UnorderedList}
icon={Icons.UnorderList}
tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`} />}
/>
<HeadingBlockButton />
</Box>
{canEscape && (
<>
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
<Box shrink="No" gap="100">
<ExitFormatting
tooltip={
<BtnTooltip text="Exit Formatting" shortCode={`Escape, ${modKey} + E`} />
}
/>
</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

@ -1,35 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
export const AutocompleteMenuBase = style([
DefaultReset,
{
position: 'relative',
},
]);
export const AutocompleteMenuContainer = style([
DefaultReset,
{
position: 'absolute',
bottom: config.space.S200,
left: 0,
right: 0,
zIndex: config.zIndex.Max,
},
]);
export const AutocompleteMenu = style([
DefaultReset,
{
maxHeight: '30vh',
height: '100%',
display: 'flex',
flexDirection: 'column',
},
]);
export const AutocompleteMenuHeader = style([
DefaultReset,
{ padding: `0 ${config.space.S300}`, flexShrink: 0 },
]);

View file

@ -1,41 +0,0 @@
import React, { ReactNode } from 'react';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Header, Menu, Scroll, config } from 'folds';
import * as css from './AutocompleteMenu.css';
import { preventScrollWithArrowKey } from '../../../utils/keyboard';
type AutocompleteMenuProps = {
requestClose: () => void;
headerContent: ReactNode;
children: ReactNode;
};
export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) {
return (
<div className={css.AutocompleteMenuBase}>
<div className={css.AutocompleteMenuContainer}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => requestClose(),
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
allowOutsideClick: true,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu className={css.AutocompleteMenu}>
<Header className={css.AutocompleteMenuHeader} size="400">
{headerContent}
</Header>
<Scroll style={{ flexGrow: 1 }} onKeyDown={preventScrollWithArrowKey}>
<div style={{ padding: config.space.S200 }}>{children}</div>
</Scroll>
</Menu>
</FocusTrap>
</div>
</div>
);
}

View file

@ -1,130 +0,0 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
import { Editor } from 'slate';
import { Box, MenuItem, Text, toRem } from 'folds';
import { Room } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
SearchItemStrGetter,
UseAsyncSearchOptions,
useAsyncSearch,
} from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji';
import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji';
import { useKeyDown } from '../../../hooks/useKeyDown';
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
type EmoticonSearchItem = ExtendedPackImage | IEmoji;
type EmoticonAutocompleteProps = {
imagePackRooms: Room[];
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: {
contain: true,
},
};
const getEmoticonStr: SearchItemStrGetter<EmoticonSearchItem> = (emoticon) => [
`:${emoticon.shortcode}:`,
];
export function EmoticonAutocomplete({
imagePackRooms,
editor,
query,
requestClose,
}: EmoticonAutocompleteProps) {
const mx = useMatrixClient();
const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20);
const searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = [];
return list.concat(
imagePacks.flatMap((pack) => pack.getImagesFor(PackUsage.Emoticon)),
emojis
);
}, [imagePacks]);
const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
const autoCompleteEmoticon = result ? result.items : recentEmoji;
useEffect(() => {
if (query.text) search(query.text);
else resetSearch();
}, [query.text, search, resetSearch]);
const handleAutocomplete: EmoticonCompleteHandler = (key, shortcode) => {
const emoticonEl = createEmoticonElement(key, shortcode);
replaceWithElement(editor, query.range, emoticonEl);
moveCursor(editor, true);
requestClose();
};
useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => {
if (autoCompleteEmoticon.length === 0) return;
const emoticon = autoCompleteEmoticon[0];
const key = 'url' in emoticon ? emoticon.url : emoticon.unicode;
handleAutocomplete(key, emoticon.shortcode);
});
});
return autoCompleteEmoticon.length === 0 ? null : (
<AutocompleteMenu headerContent={<Text size="L400">Emojis</Text>} requestClose={requestClose}>
{autoCompleteEmoticon.map((emoticon) => {
const isCustomEmoji = 'url' in emoticon;
const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
return (
<MenuItem
key={emoticon.shortcode + key}
as="button"
radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(key, emoticon.shortcode))
}
onClick={() => handleAutocomplete(key, emoticon.shortcode)}
before={
isCustomEmoji ? (
<Box
shrink="No"
as="img"
src={mx.mxcUrlToHttp(key) || key}
alt={emoticon.shortcode}
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
/>
) : (
<Box
shrink="No"
as="span"
display="InlineFlex"
style={{ fontSize: toRem(24), lineHeight: toRem(24) }}
>
{key}
</Box>
)
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
:{emoticon.shortcode}:
</Text>
</MenuItem>
);
})}
</AutocompleteMenu>
);
}

View file

@ -1,185 +0,0 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
import { Editor } from 'slate';
import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room';
import { roomIdByActivity } from '../../../../util/sort';
import initMatrix from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { getMxIdServer, validMxId } from '../../../utils/matrix';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { useKeyDown } from '../../../hooks/useKeyDown';
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
validMxId(`#${text}`)
? `#${text}`
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownRoomMentionItem({
query,
handleAutocomplete,
}: {
query: AutocompleteQuery<string>;
handleAutocomplete: MentionAutoCompleteHandler;
}) {
const mx = useMatrixClient();
const roomAlias: string = roomAliasFromQueryText(mx, query.text);
const handleSelect = () => handleAutocomplete(roomAlias, roomAlias);
return (
<MenuItem
as="button"
radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) => onTabPress(evt, handleSelect)}
onClick={handleSelect}
before={
<Avatar size="200">
<Icon src={Icons.Hash} size="100" />
</Avatar>
}
>
<Text style={{ flexGrow: 1 }} size="B400">
{roomAlias}
</Text>
</MenuItem>
);
}
type RoomMentionAutocompleteProps = {
roomId: string;
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: {
contain: true,
},
};
export function RoomMentionAutocomplete({
roomId,
editor,
query,
requestClose,
}: RoomMentionAutocompleteProps) {
const mx = useMatrixClient();
const dms: Set<string> = initMatrix.roomList?.directs ?? new Set();
const allRoomId: string[] = useMemo(() => {
const { spaces = [], rooms = [], directs = [] } = initMatrix.roomList ?? {};
return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
}, []);
const [result, search, resetSearch] = useAsyncSearch(
allRoomId,
useCallback(
(rId) => {
const r = mx.getRoom(rId);
if (!r) return 'Unknown Room';
const alias = r.getCanonicalAlias();
if (alias) return [r.name, alias];
return r.name;
},
[mx]
),
SEARCH_OPTIONS
);
const autoCompleteRoomIds = result ? result.items : allRoomId.slice(0, 20);
useEffect(() => {
if (query.text) search(query.text);
else resetSearch();
}, [query.text, search, resetSearch]);
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
const mentionEl = createMentionElement(
roomAliasOrId,
name.startsWith('#') ? name : `#${name}`,
roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId
);
replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true);
requestClose();
};
useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => {
if (autoCompleteRoomIds.length === 0) {
const alias = roomAliasFromQueryText(mx, query.text);
handleAutocomplete(alias, alias);
return;
}
const rId = autoCompleteRoomIds[0];
const r = mx.getRoom(rId);
const name = r?.name ?? rId;
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
});
});
return (
<AutocompleteMenu headerContent={<Text size="L400">Rooms</Text>} requestClose={requestClose}>
{autoCompleteRoomIds.length === 0 ? (
<UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} />
) : (
autoCompleteRoomIds.map((rId) => {
const room = mx.getRoom(rId);
if (!room) return null;
const dm = dms.has(room.roomId);
const avatarUrl = getRoomAvatarUrl(mx, room);
const iconSrc = !dm && joinRuleToIconSrc(Icons, room.getJoinRule(), room.isSpaceRoom());
const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
return (
<MenuItem
key={rId}
as="button"
radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, handleSelect)
}
onClick={handleSelect}
after={
<Text size="T200" priority="300" truncate>
{room.getCanonicalAlias() ?? ''}
</Text>
}
before={
<Avatar size="200">
{iconSrc && <Icon src={iconSrc} size="100" />}
{avatarUrl && !iconSrc && <AvatarImage src={avatarUrl} alt={room.name} />}
{!avatarUrl && !iconSrc && (
<AvatarFallback
style={{
backgroundColor: color.Secondary.Container,
color: color.Secondary.OnContainer,
}}
>
<Text size="H6">{room.name[0]}</Text>
</AvatarFallback>
)}
</Avatar>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{room.name}
</Text>
</MenuItem>
);
})
)}
</AutocompleteMenu>
);
}

View file

@ -1,194 +0,0 @@
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Editor } from 'slate';
import { Avatar, AvatarFallback, AvatarImage, MenuItem, Text, color } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { useRoomMembers } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
SearchItemStrGetter,
UseAsyncSearchOptions,
useAsyncSearch,
} from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
validMxId(`@${text}`)
? `@${text}`
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownMentionItem({
query,
userId,
name,
handleAutocomplete,
}: {
query: AutocompleteQuery<string>;
userId: string;
name: string;
handleAutocomplete: MentionAutoCompleteHandler;
}) {
return (
<MenuItem
as="button"
radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(userId, name))
}
onClick={() => handleAutocomplete(userId, name)}
before={
<Avatar size="200">
<AvatarFallback
style={{
backgroundColor: color.Secondary.Container,
color: color.Secondary.OnContainer,
}}
>
<Text size="H6">{query.text[0]}</Text>
</AvatarFallback>
</Avatar>
}
>
<Text style={{ flexGrow: 1 }} size="B400">
{name}
</Text>
</MenuItem>
);
}
type UserMentionAutocompleteProps = {
room: Room;
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: {
contain: true,
},
};
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
getMemberSearchStr(m, query, mxIdToName);
export function UserMentionAutocomplete({
room,
editor,
query,
requestClose,
}: UserMentionAutocompleteProps) {
const mx = useMatrixClient();
const roomId: string = room.roomId!;
const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId);
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
const autoCompleteMembers = result ? result.items : members.slice(0, 20);
useEffect(() => {
if (query.text) search(query.text);
else resetSearch();
}, [query.text, search, resetSearch]);
const handleAutocomplete: MentionAutoCompleteHandler = (uId, name) => {
const mentionEl = createMentionElement(
uId,
name.startsWith('@') ? name : `@${name}`,
mx.getUserId() === uId || roomAliasOrId === uId
);
replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true);
requestClose();
};
useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => {
if (query.text === 'room') {
handleAutocomplete(roomAliasOrId, '@room');
return;
}
if (autoCompleteMembers.length === 0) {
const userId = userIdFromQueryText(mx, query.text);
handleAutocomplete(userId, userId);
return;
}
const roomMember = autoCompleteMembers[0];
handleAutocomplete(roomMember.userId, roomMember.name);
});
});
const getName = (member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
return (
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
{query.text === 'room' && (
<UnknownMentionItem
query={query}
userId={roomAliasOrId}
name="@room"
handleAutocomplete={handleAutocomplete}
/>
)}
{autoCompleteMembers.length === 0 ? (
<UnknownMentionItem
query={query}
userId={userIdFromQueryText(mx, query.text)}
name={userIdFromQueryText(mx, query.text)}
handleAutocomplete={handleAutocomplete}
/>
) : (
autoCompleteMembers.map((roomMember) => {
const avatarUrl = roomMember.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false);
return (
<MenuItem
key={roomMember.userId}
as="button"
radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(roomMember.userId, getName(roomMember)))
}
onClick={() => handleAutocomplete(roomMember.userId, getName(roomMember))}
after={
<Text size="T200" priority="300" truncate>
{roomMember.userId}
</Text>
}
before={
<Avatar size="200">
{avatarUrl ? (
<AvatarImage src={avatarUrl} alt={getName(roomMember)} />
) : (
<AvatarFallback
style={{
backgroundColor: color.Secondary.Container,
color: color.Secondary.OnContainer,
}}
>
<Text size="H6">{getName(roomMember)[0]}</Text>
</AvatarFallback>
)}
</Avatar>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{getName(roomMember)}
</Text>
</MenuItem>
);
})
)}
</AutocompleteMenu>
);
}

View file

@ -1,49 +0,0 @@
import { BaseRange, Editor } from 'slate';
export enum AutocompletePrefix {
RoomMention = '#',
UserMention = '@',
Emoticon = ':',
Command = '/',
}
export const AUTOCOMPLETE_PREFIXES: readonly AutocompletePrefix[] = [
AutocompletePrefix.RoomMention,
AutocompletePrefix.UserMention,
AutocompletePrefix.Emoticon,
AutocompletePrefix.Command,
];
export type AutocompleteQuery<TPrefix extends string> = {
range: BaseRange;
prefix: TPrefix;
text: string;
};
export const getAutocompletePrefix = <TPrefix extends string>(
editor: Editor,
queryRange: BaseRange,
validPrefixes: readonly TPrefix[]
): TPrefix | undefined => {
const world = Editor.string(editor, queryRange);
return validPrefixes.find((p) => world.startsWith(p));
};
export const getAutocompleteQueryText = (
editor: Editor,
queryRange: BaseRange,
prefix: string
): string => Editor.string(editor, queryRange).slice(prefix.length);
export const getAutocompleteQuery = <TPrefix extends string>(
editor: Editor,
queryRange: BaseRange,
validPrefixes: readonly TPrefix[]
): AutocompleteQuery<TPrefix> | undefined => {
const prefix = getAutocompletePrefix(editor, queryRange, validPrefixes);
if (!prefix) return undefined;
return {
range: queryRange,
prefix,
text: getAutocompleteQueryText(editor, queryRange, prefix),
};
};

View file

@ -1,5 +0,0 @@
export * from './AutocompleteMenu';
export * from './autocompleteQuery';
export * from './RoomMentionAutocomplete';
export * from './UserMentionAutocomplete';
export * from './EmoticonAutocomplete';

View file

@ -1,9 +0,0 @@
export * from './autocomplete';
export * from './utils';
export * from './Editor';
export * from './Elements';
export * from './keyboard';
export * from './output';
export * from './Toolbar';
export * from './input';
export * from './types';

View file

@ -1,385 +0,0 @@
/* eslint-disable no-param-reassign */
import { Descendant, Text } from 'slate';
import parse from 'html-dom-parser';
import { ChildNode, Element, isText, isTag } from 'domhandler';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { BlockType, MarkType } from './types';
import {
BlockQuoteElement,
CodeBlockElement,
CodeLineElement,
EmoticonElement,
HeadingElement,
HeadingLevel,
InlineElement,
MentionElement,
OrderedListElement,
ParagraphElement,
UnorderedListElement,
} from './slate';
import { parseMatrixToUrl } from '../../utils/matrix';
import { createEmoticonElement, createMentionElement } from './utils';
const markNodeToType: Record<string, MarkType> = {
b: MarkType.Bold,
strong: MarkType.Bold,
i: MarkType.Italic,
em: MarkType.Italic,
u: MarkType.Underline,
s: MarkType.StrikeThrough,
del: MarkType.StrikeThrough,
code: MarkType.Code,
span: MarkType.Spoiler,
};
const elementToTextMark = (node: Element): MarkType | undefined => {
const markType = markNodeToType[node.name];
if (!markType) return undefined;
if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) {
return undefined;
}
if (
markType === MarkType.Code &&
node.parent &&
'name' in node.parent &&
node.parent.name === 'pre'
) {
return undefined;
}
return markType;
};
const parseNodeText = (node: ChildNode): string => {
if (isText(node)) {
return node.data;
}
if (isTag(node)) {
return node.children.map((child) => parseNodeText(child)).join('');
}
return '';
};
const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => {
if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
const { src, alt } = node.attribs;
if (!src) return undefined;
return createEmoticonElement(src, alt || 'Unknown Emoji');
}
if (node.name === 'a') {
const { href } = node.attribs;
if (typeof href !== 'string') return undefined;
const [mxId] = parseMatrixToUrl(href);
if (mxId) {
return createMentionElement(mxId, parseNodeText(node) || mxId, false);
}
}
return undefined;
};
const parseInlineNodes = (node: ChildNode): InlineElement[] => {
if (isText(node)) {
return [{ text: node.data }];
}
if (isTag(node)) {
const markType = elementToTextMark(node);
if (markType) {
const children = node.children.flatMap(parseInlineNodes);
if (node.attribs['data-md'] !== undefined) {
children.unshift({ text: node.attribs['data-md'] });
children.push({ text: node.attribs['data-md'] });
} else {
children.forEach((child) => {
if (Text.isText(child)) {
child[markType] = true;
}
});
}
return children;
}
const inlineNode = elementToInlineNode(node);
if (inlineNode) return [inlineNode];
if (node.name === 'a') {
const children = node.childNodes.flatMap(parseInlineNodes);
children.unshift({ text: '[' });
children.push({ text: `](${node.attribs.href})` });
return children;
}
return node.childNodes.flatMap(parseInlineNodes);
}
return [];
};
const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
const quoteLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = [];
const appendLine = () => {
if (lineHolder.length === 0) return;
quoteLines.push(lineHolder);
lineHolder = [];
};
node.children.forEach((child) => {
if (isText(child)) {
lineHolder.push({ text: child.data });
return;
}
if (isTag(child)) {
if (child.name === 'br') {
lineHolder.push({ text: '' });
appendLine();
return;
}
if (child.name === 'p') {
appendLine();
quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
return;
}
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
}
});
appendLine();
if (node.attribs['data-md'] !== undefined) {
return quoteLines.map((lineChildren) => ({
type: BlockType.Paragraph,
children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
}));
}
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: 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: codeLines.map<CodeLineElement>((lineTxt) => ({
type: BlockType.CodeLine,
children: [
{
text: lineTxt,
},
],
})),
},
];
};
const parseListNode = (
node: Element
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
const listLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = [];
const appendLine = () => {
if (lineHolder.length === 0) return;
listLines.push(lineHolder);
lineHolder = [];
};
node.children.forEach((child) => {
if (isText(child)) {
lineHolder.push({ text: child.data });
return;
}
if (isTag(child)) {
if (child.name === 'br') {
lineHolder.push({ text: '' });
appendLine();
return;
}
if (child.name === 'li') {
appendLine();
listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
return;
}
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
}
});
appendLine();
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 | 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,
children,
};
};
export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
const children: Descendant[] = [];
let lineHolder: InlineElement[] = [];
const appendLine = () => {
if (lineHolder.length === 0) return;
children.push({
type: BlockType.Paragraph,
children: lineHolder,
});
lineHolder = [];
};
domNodes.forEach((node) => {
if (isText(node)) {
lineHolder.push({ text: node.data });
return;
}
if (isTag(node)) {
if (node.name === 'br') {
lineHolder.push({ text: '' });
appendLine();
return;
}
if (node.name === 'p') {
appendLine();
children.push({
type: BlockType.Paragraph,
children: node.children.flatMap((child) => parseInlineNodes(child)),
});
return;
}
if (node.name === 'blockquote') {
appendLine();
children.push(...parseBlockquoteNode(node));
return;
}
if (node.name === 'pre') {
appendLine();
children.push(...parseCodeBlockNode(node));
return;
}
if (node.name === 'ol' || node.name === 'ul') {
appendLine();
children.push(...parseListNode(node));
return;
}
if (node.name.match(/^h[123456]$/)) {
appendLine();
children.push(parseHeadingNode(node));
return;
}
parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
}
});
appendLine();
return children;
};
export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
const domNodes = parse(sanitizedHtml);
const editorNodes = domToEditorInput(domNodes);
return editorNodes;
};
export const plainToEditorInput = (text: string): Descendant[] => {
const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
const paragraphNode: ParagraphElement = {
type: BlockType.Paragraph,
children: [
{
text: lineText,
},
],
};
return paragraphNode;
});
return editorNodes;
};

View file

@ -1,116 +0,0 @@
import { isKeyHotkey } from 'is-hotkey';
import { KeyboardEvent } from 'react';
import { Editor, Element as SlateElement, Range, Transforms } from 'slate';
import { isAnyMarkActive, isBlockActive, removeAllMark, toggleBlock, toggleMark } from './utils';
import { BlockType, MarkType } from './types';
export const INLINE_HOTKEYS: Record<string, MarkType> = {
'mod+b': MarkType.Bold,
'mod+i': MarkType.Italic,
'mod+u': MarkType.Underline,
'mod+s': MarkType.StrikeThrough,
'mod+[': MarkType.Code,
'mod+h': MarkType.Spoiler,
};
const INLINE_KEYS = Object.keys(INLINE_HOTKEYS);
export const BLOCK_HOTKEYS: Record<string, BlockType> = {
'mod+7': BlockType.OrderedList,
'mod+8': BlockType.UnorderedList,
"mod+'": BlockType.BlockQuote,
'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.
*/
export const toggleKeyboardShortcut = (editor: Editor, event: KeyboardEvent<Element>): boolean => {
if (isKeyHotkey('backspace', event) && editor.selection && Range.isCollapsed(editor.selection)) {
const startPoint = Range.start(editor.selection);
if (startPoint.offset !== 0) return false;
const [parentNode, parentPath] = Editor.parent(editor, startPoint);
const parentLocation = { at: parentPath };
const [previousNode] = Editor.previous(editor, parentLocation) ?? [];
const [nextNode] = Editor.next(editor, parentLocation) ?? [];
if (Editor.isEditor(parentNode)) return false;
if (parentNode.type === BlockType.Heading) {
toggleBlock(editor, BlockType.Paragraph);
return true;
}
if (
parentNode.type === BlockType.CodeLine ||
parentNode.type === BlockType.QuoteLine ||
parentNode.type === BlockType.ListItem
) {
// exit formatting only when line block
// is first of last of it's parent
if (!previousNode || !nextNode) {
toggleBlock(editor, BlockType.Paragraph);
return true;
}
}
// Unwrap paragraph children to put them
// in previous none paragraph element
if (SlateElement.isElement(previousNode) && previousNode.type !== BlockType.Paragraph) {
Transforms.unwrapNodes(editor, {
at: startPoint,
});
}
Editor.deleteBackward(editor);
return true;
}
if (isKeyHotkey('mod+e', event) || isKeyHotkey('escape', event)) {
if (isAnyMarkActive(editor)) {
removeAllMark(editor);
return true;
}
if (!isBlockActive(editor, BlockType.Paragraph)) {
toggleBlock(editor, BlockType.Paragraph);
return true;
}
return false;
}
const blockToggled = BLOCK_KEYS.find((hotkey) => {
if (isKeyHotkey(hotkey, event)) {
event.preventDefault();
toggleBlock(editor, BLOCK_HOTKEYS[hotkey]);
return true;
}
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
: INLINE_KEYS.find((hotkey) => {
if (isKeyHotkey(hotkey, event)) {
event.preventDefault();
toggleMark(editor, INLINE_HOTKEYS[hotkey]);
return true;
}
return false;
});
return !!inlineToggled;
};

View file

@ -1,178 +0,0 @@
import { Descendant, Text } from 'slate';
import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './types';
import { CustomElement } from './slate';
import { parseBlockMD, parseInlineMD } from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace';
export type OutputOptions = {
allowTextFormatting?: boolean;
allowInlineMarkdown?: boolean;
allowBlockMarkdown?: boolean;
};
const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
let string = sanitizeText(node.text);
if (opts.allowTextFormatting) {
if (node.bold) string = `<strong>${string}</strong>`;
if (node.italic) string = `<i>${string}</i>`;
if (node.underline) string = `<u>${string}</u>`;
if (node.strikeThrough) string = `<del>${string}</del>`;
if (node.code) string = `<code>${string}</code>`;
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
}
if (opts.allowInlineMarkdown && string === sanitizeText(node.text)) {
string = parseInlineMD(string);
}
return string;
};
const elementToCustomHtml = (node: CustomElement, children: string): string => {
switch (node.type) {
case BlockType.Paragraph:
return `${children}<br/>`;
case BlockType.Heading:
return `<h${node.level}>${children}</h${node.level}>`;
case BlockType.CodeLine:
return `${children}\n`;
case BlockType.CodeBlock:
return `<pre><code>${children}</code></pre>`;
case BlockType.QuoteLine:
return `${children}<br/>`;
case BlockType.BlockQuote:
return `<blockquote>${children}</blockquote>`;
case BlockType.ListItem:
return `<li><p>${children}</p></li>`;
case BlockType.OrderedList:
return `<ol>${children}</ol>`;
case BlockType.UnorderedList:
return `<ul>${children}</ul>`;
case BlockType.Mention:
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="${sanitizeText(
node.shortcode
)}" title="${sanitizeText(node.shortcode)}" height="32" />`
: sanitizeText(node.key);
case BlockType.Link:
return `<a href="${encodeURIComponent(node.href)}">${node.children}</a>`;
case BlockType.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 => {
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 `${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);
const children = node.children.map(parseNode).join('');
return elementToCustomHtml(node, children);
};
const elementToPlainText = (node: CustomElement, children: string): string => {
switch (node.type) {
case BlockType.Paragraph:
return `${children}\n`;
case BlockType.Heading:
return `${children}\n`;
case BlockType.CodeLine:
return `${children}\n`;
case BlockType.CodeBlock:
return `${children}\n`;
case BlockType.QuoteLine:
return `| ${children}\n`;
case BlockType.BlockQuote:
return `${children}\n`;
case BlockType.ListItem:
return `- ${children}\n`;
case BlockType.OrderedList:
return `${children}\n`;
case BlockType.UnorderedList:
return `${children}\n`;
case BlockType.Mention:
return node.id;
case BlockType.Emoticon:
return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key;
case BlockType.Link:
return `[${node.children}](${node.href})`;
case BlockType.Command:
return `/${node.command}`;
default:
return children;
}
};
export const toPlainText = (node: Descendant | Descendant[]): string => {
if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join('');
if (Text.isText(node)) return node.text;
const children = node.children.map((n) => toPlainText(n)).join('');
return elementToPlainText(node, children);
};
/**
* Check if customHtml is equals to plainText
* by replacing `<br/>` with `/n` in customHtml
* and sanitizing plainText before comparison
* because text are sanitized in customHtml
* @param customHtml string
* @param plain string
* @returns boolean
*/
export const customHtmlEqualsPlainText = (customHtml: string, plain: string): boolean =>
customHtml.replace(/<br\/>/g, '\n') === sanitizeText(plain);
export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim();
export const trimCommand = (cmdName: string, str: string) => {
const cmdRegX = new RegExp(`^(\\s+)?(\\/${cmdName})([^\\S\n]+)?`);
const match = str.match(cmdRegX);
if (!match) return str;
return str.slice(match[0].length);
};

View file

@ -1,109 +0,0 @@
import { BaseEditor } from 'slate';
import { ReactEditor } from 'slate-react';
import { HistoryEditor } from 'slate-history';
import { BlockType } from './types';
export type HeadingLevel = 1 | 2 | 3;
export type Editor = BaseEditor & HistoryEditor & ReactEditor;
export type Text = {
text: string;
};
export type FormattedText = Text & {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikeThrough?: boolean;
code?: boolean;
spoiler?: boolean;
};
export type LinkElement = {
type: BlockType.Link;
href: string;
children: Text[];
};
export type MentionElement = {
type: BlockType.Mention;
id: string;
highlight: boolean;
name: string;
children: Text[];
};
export type EmoticonElement = {
type: BlockType.Emoticon;
key: string;
shortcode: string;
children: Text[];
};
export type CommandElement = {
type: BlockType.Command;
command: string;
children: Text[];
};
export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement | CommandElement;
export type ParagraphElement = {
type: BlockType.Paragraph;
children: InlineElement[];
};
export type HeadingElement = {
type: BlockType.Heading;
level: HeadingLevel;
children: InlineElement[];
};
export type CodeLineElement = {
type: BlockType.CodeLine;
children: Text[];
};
export type CodeBlockElement = {
type: BlockType.CodeBlock;
children: CodeLineElement[];
};
export type QuoteLineElement = {
type: BlockType.QuoteLine;
children: InlineElement[];
};
export type BlockQuoteElement = {
type: BlockType.BlockQuote;
children: QuoteLineElement[];
};
export type ListItemElement = {
type: BlockType.ListItem;
children: InlineElement[];
};
export type OrderedListElement = {
type: BlockType.OrderedList;
children: ListItemElement[];
};
export type UnorderedListElement = {
type: BlockType.UnorderedList;
children: ListItemElement[];
};
export type CustomElement =
| LinkElement
| MentionElement
| EmoticonElement
| CommandElement
| ParagraphElement
| HeadingElement
| CodeLineElement
| CodeBlockElement
| QuoteLineElement
| BlockQuoteElement
| ListItemElement
| OrderedListElement
| UnorderedListElement;
declare module 'slate' {
interface CustomTypes {
Editor: Editor;
Element: CustomElement;
Text: FormattedText & Text;
}
}

View file

@ -1,24 +0,0 @@
export enum MarkType {
Bold = 'bold',
Italic = 'italic',
Underline = 'underline',
StrikeThrough = 'strikeThrough',
Code = 'code',
Spoiler = 'spoiler',
}
export enum BlockType {
Paragraph = 'paragraph',
Heading = 'heading',
CodeLine = 'code-line',
CodeBlock = 'code-block',
QuoteLine = 'quote-line',
BlockQuote = 'block-quote',
ListItem = 'list-item',
OrderedList = 'ordered-list',
UnorderedList = 'unordered-list',
Mention = 'mention',
Emoticon = 'emoticon',
Link = 'link',
Command = 'command',
}

View file

@ -1,271 +0,0 @@
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
import { BlockType, MarkType } from './types';
import {
CommandElement,
EmoticonElement,
FormattedText,
HeadingLevel,
LinkElement,
MentionElement,
} from './slate';
const ALL_MARK_TYPE: MarkType[] = [
MarkType.Bold,
MarkType.Code,
MarkType.Italic,
MarkType.Spoiler,
MarkType.StrikeThrough,
MarkType.Underline,
];
export const isMarkActive = (editor: Editor, format: MarkType) => {
const marks = Editor.marks(editor);
return marks ? marks[format] === true : false;
};
export const isAnyMarkActive = (editor: Editor) => {
const marks = Editor.marks(editor);
return marks && !!ALL_MARK_TYPE.find((type) => marks[type] === true);
};
export const toggleMark = (editor: Editor, format: MarkType) => {
const isActive = isMarkActive(editor, format);
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};
export const removeAllMark = (editor: Editor) => {
ALL_MARK_TYPE.forEach((mark) => {
if (isMarkActive(editor, mark)) Editor.removeMark(editor, mark);
});
};
export const isBlockActive = (editor: Editor, format: BlockType) => {
const [match] = Editor.nodes(editor, {
match: (node) => Element.isElement(node) && node.type === format,
});
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,
BlockType.UnorderedList,
BlockType.BlockQuote,
BlockType.CodeBlock,
];
export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => {
Transforms.collapse(editor, {
edge: 'end',
});
const isActive = isBlockActive(editor, format);
Transforms.unwrapNodes(editor, {
match: (node) => Element.isElement(node) && NESTED_BLOCK.includes(node.type),
split: true,
});
if (isActive) {
Transforms.setNodes(editor, {
type: BlockType.Paragraph,
});
return;
}
if (format === BlockType.OrderedList || format === BlockType.UnorderedList) {
Transforms.setNodes(editor, {
type: BlockType.ListItem,
});
const block = {
type: format,
children: [],
};
Transforms.wrapNodes(editor, block);
return;
}
if (format === BlockType.CodeBlock) {
Transforms.setNodes(editor, {
type: BlockType.CodeLine,
});
const block = {
type: format,
children: [],
};
Transforms.wrapNodes(editor, block);
return;
}
if (format === BlockType.BlockQuote) {
Transforms.setNodes(editor, {
type: BlockType.QuoteLine,
});
const block = {
type: format,
children: [],
};
Transforms.wrapNodes(editor, block);
return;
}
if (format === BlockType.Heading) {
Transforms.setNodes(editor, {
type: format,
level: option?.level ?? 1,
});
}
Transforms.setNodes(editor, {
type: format,
});
};
export const resetEditor = (editor: Editor) => {
Transforms.delete(editor, {
at: {
anchor: Editor.start(editor, []),
focus: Editor.end(editor, []),
},
});
toggleBlock(editor, BlockType.Paragraph);
removeAllMark(editor);
};
export const resetEditorHistory = (editor: Editor) => {
// eslint-disable-next-line no-param-reassign
editor.history = {
undos: [],
redos: [],
};
};
export const createMentionElement = (
id: string,
name: string,
highlight: boolean
): MentionElement => ({
type: BlockType.Mention,
id,
highlight,
name,
children: [{ text: '' }],
});
export const createEmoticonElement = (key: string, shortcode: string): EmoticonElement => ({
type: BlockType.Emoticon,
key,
shortcode,
children: [{ text: '' }],
});
export const createLinkElement = (
href: string,
children: string | FormattedText[]
): LinkElement => ({
type: BlockType.Link,
href,
children: typeof children === 'string' ? [{ text: children }] : children,
});
export const createCommandElement = (command: string): CommandElement => ({
type: BlockType.Command,
command,
children: [{ text: '' }],
});
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
Transforms.select(editor, selectRange);
Transforms.insertNodes(editor, element);
Transforms.collapse(editor, {
edge: 'end',
});
};
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
Transforms.move(editor);
if (withSpace) editor.insertText(' ');
};
interface PointUntilCharOptions {
match: (char: string) => boolean;
reverse?: boolean;
}
export const getPointUntilChar = (
editor: Editor,
cursorPoint: BasePoint,
options: PointUntilCharOptions
): BasePoint | undefined => {
let targetPoint: BasePoint | undefined;
let prevPoint: BasePoint | undefined;
let char: string | undefined;
const pointItr = Editor.positions(editor, {
at: {
anchor: Editor.start(editor, []),
focus: Editor.point(editor, cursorPoint, { edge: 'start' }),
},
unit: 'character',
reverse: options.reverse,
});
// eslint-disable-next-line no-restricted-syntax
for (const point of pointItr) {
if (!Point.equals(point, cursorPoint) && prevPoint) {
char = Editor.string(editor, { anchor: point, focus: prevPoint });
if (options.match(char)) break;
targetPoint = point;
}
prevPoint = point;
}
return targetPoint;
};
export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
const { selection } = editor;
if (!selection || !Range.isCollapsed(selection)) return undefined;
const [cursorPoint] = Range.edges(selection);
const worldStartPoint = getPointUntilChar(editor, cursorPoint, {
reverse: true,
match: (char) => char === ' ',
});
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
};
export const isEmptyEditor = (editor: Editor): boolean => {
const firstChildren = editor.children[0];
if (firstChildren && Element.isElement(firstChildren)) {
const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
return isEmpty;
}
return false;
};
export const getBeginCommand = (editor: Editor): string | undefined => {
const lineBlock = editor.children[0];
if (!Element.isElement(lineBlock)) return undefined;
if (lineBlock.type !== BlockType.Paragraph) return undefined;
const [firstInline, secondInline] = lineBlock.children;
const isEmptyText = Text.isText(firstInline) && firstInline.text.trim() === '';
if (!isEmptyText) return undefined;
if (Element.isElement(secondInline) && secondInline.type === BlockType.Command)
return secondInline.command;
return undefined;
};

View file

@ -1,136 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
export const Base = style({
maxWidth: toRem(432),
width: `calc(100vw - 2 * ${config.space.S400})`,
height: toRem(450),
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R400,
boxShadow: config.shadow.E200,
overflow: 'hidden',
});
export const Sidebar = style({
width: toRem(54),
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
position: 'relative',
});
export const SidebarContent = style({
padding: `${config.space.S200} 0`,
});
export const SidebarStack = style({
width: '100%',
backgroundColor: color.Surface.Container,
});
export const NativeEmojiSidebarStack = style({
position: 'sticky',
bottom: '-67%',
zIndex: 1,
});
export const SidebarDivider = style({
width: toRem(18),
});
export const Header = style({
padding: config.space.S300,
paddingBottom: 0,
});
export const EmojiBoardTab = style({
cursor: 'pointer',
});
export const Footer = style({
padding: config.space.S200,
margin: config.space.S300,
marginTop: 0,
minHeight: toRem(40),
borderRadius: config.radii.R400,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
});
export const EmojiGroup = style({
padding: `${config.space.S300} 0`,
});
export const EmojiGroupLabel = style({
position: 'sticky',
top: config.space.S200,
zIndex: 1,
margin: 'auto',
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.Pill,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
});
export const EmojiGroupContent = style([
DefaultReset,
{
padding: `0 ${config.space.S200}`,
},
]);
export const EmojiPreview = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
fontSize: toRem(32),
lineHeight: toRem(32),
},
]);
export const EmojiItem = style([
DefaultReset,
FocusOutline,
{
width: toRem(48),
height: toRem(48),
fontSize: toRem(32),
lineHeight: toRem(32),
borderRadius: config.radii.R400,
cursor: 'pointer',
':hover': {
backgroundColor: color.Surface.ContainerHover,
},
},
]);
export const StickerItem = style([
EmojiItem,
{
width: toRem(112),
height: toRem(112),
},
]);
export const CustomEmojiImg = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
objectFit: 'contain',
},
]);
export const StickerImg = style([
DefaultReset,
{
width: toRem(96),
height: toRem(96),
objectFit: 'contain',
},
]);

View file

@ -1,906 +0,0 @@
import React, {
ChangeEventHandler,
FocusEventHandler,
MouseEventHandler,
UIEventHandler,
ReactNode,
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import {
Badge,
Box,
Chip,
Icon,
IconButton,
Icons,
Input,
Line,
Scroll,
Text,
Tooltip,
TooltipProvider,
as,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import classNames from 'classnames';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import * as css from './EmojiBoard.css';
import { EmojiGroupId, IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji';
import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels';
import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
import { isUserId } from '../../utils/matrix';
import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce';
import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji';
import { mobileOrTablet } from '../../utils/user-agent';
const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
export enum EmojiBoardTab {
Emoji = 'Emoji',
Sticker = 'Sticker',
}
enum EmojiType {
Emoji = 'emoji',
CustomEmoji = 'customEmoji',
Sticker = 'sticker',
}
export type EmojiItemInfo = {
type: EmojiType;
data: string;
shortcode: string;
label: string;
};
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 && label)
return {
type,
data,
shortcode,
label,
};
return undefined;
};
const activeGroupIdAtom = atom<string | undefined>(undefined);
function Sidebar({ children }: { children: ReactNode }) {
return (
<Box className={css.Sidebar} shrink="No">
<Scroll size="0">
<Box className={css.SidebarContent} direction="Column" alignItems="Center" gap="100">
{children}
</Box>
</Scroll>
</Box>
);
}
const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => (
<Box
className={classNames(css.SidebarStack, className)}
direction="Column"
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
{children}
</Box>
));
function SidebarDivider() {
return <Line className={css.SidebarDivider} size="300" variant="Surface" />;
}
function Header({ children }: { children: ReactNode }) {
return (
<Box className={css.Header} direction="Column" shrink="No">
{children}
</Box>
);
}
function Content({ children }: { children: ReactNode }) {
return <Box grow="Yes">{children}</Box>;
}
function Footer({ children }: { children: ReactNode }) {
return (
<Box shrink="No" className={css.Footer} gap="300" alignItems="Center">
{children}
</Box>
);
}
const EmojiBoardLayout = as<
'div',
{
header: ReactNode;
sidebar?: ReactNode;
footer?: ReactNode;
children: ReactNode;
}
>(({ className, header, sidebar, footer, children, ...props }, ref) => (
<Box
display="InlineFlex"
className={classNames(css.Base, className)}
direction="Row"
{...props}
ref={ref}
>
<Box direction="Column" grow="Yes">
{header}
{children}
{footer}
</Box>
<Line size="300" direction="Vertical" />
{sidebar}
</Box>
));
function EmojiBoardTabs({
tab,
onTabChange,
}: {
tab: EmojiBoardTab;
onTabChange: (tab: EmojiBoardTab) => void;
}) {
return (
<Box gap="100">
<Badge
className={css.EmojiBoardTab}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
>
<Text as="span" size="L400">
Emoji
</Text>
</Badge>
<Badge
className={css.EmojiBoardTab}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
>
<Text as="span" size="L400">
Sticker
</Text>
</Badge>
</Box>
);
}
export function SidebarBtn<T extends string>({
active,
label,
id,
onItemClick,
children,
}: {
active?: boolean;
label: string;
id: T;
onItemClick: (id: T) => void;
children: ReactNode;
}) {
return (
<TooltipProvider
delay={500}
position="Left"
tooltip={
<Tooltip id={`SidebarStackItem-${id}-label`}>
<Text size="T300">{label}</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton
aria-pressed={active}
aria-labelledby={`SidebarStackItem-${id}-label`}
ref={ref}
onClick={() => onItemClick(id)}
size="400"
radii="300"
variant="Surface"
>
{children}
</IconButton>
)}
</TooltipProvider>
);
}
export const EmojiGroup = as<
'div',
{
id: string;
label: string;
children: ReactNode;
}
>(({ className, id, label, children, ...props }, ref) => (
<Box
id={getDOMGroupId(id)}
data-group-id={id}
className={classNames(css.EmojiGroup, className)}
direction="Column"
gap="200"
{...props}
ref={ref}
>
<Text id={`EmojiGroup-${id}-label`} as="label" className={css.EmojiGroupLabel} size="O400">
{label}
</Text>
<div aria-labelledby={`EmojiGroup-${id}-label`} className={css.EmojiGroupContent}>
<Box wrap="Wrap" justifyContent="Center">
{children}
</Box>
</div>
</Box>
));
export function EmojiItem({
label,
type,
data,
shortcode,
children,
}: {
label: string;
type: EmojiType;
data: string;
shortcode: string;
children: ReactNode;
}) {
return (
<Box
as="button"
className={css.EmojiItem}
type="button"
alignItems="Center"
justifyContent="Center"
title={label}
aria-label={`${label} emoji`}
data-emoji-type={type}
data-emoji-data={data}
data-emoji-shortcode={shortcode}
>
{children}
</Box>
);
}
export function StickerItem({
label,
type,
data,
shortcode,
children,
}: {
label: string;
type: EmojiType;
data: string;
shortcode: string;
children: ReactNode;
}) {
return (
<Box
as="button"
className={css.StickerItem}
type="button"
alignItems="Center"
justifyContent="Center"
title={label}
aria-label={`${label} sticker`}
data-emoji-type={type}
data-emoji-data={data}
data-emoji-shortcode={shortcode}
>
{children}
</Box>
);
}
function RecentEmojiSidebarStack({ onItemClick }: { onItemClick: (id: string) => void }) {
const activeGroupId = useAtomValue(activeGroupIdAtom);
return (
<SidebarStack>
<SidebarBtn
active={activeGroupId === RECENT_GROUP_ID}
id={RECENT_GROUP_ID}
label="Recent"
onItemClick={() => onItemClick(RECENT_GROUP_ID)}
>
<Icon src={Icons.RecentClock} filled={activeGroupId === RECENT_GROUP_ID} />
</SidebarBtn>
</SidebarStack>
);
}
function ImagePackSidebarStack({
mx,
packs,
usage,
onItemClick,
}: {
mx: MatrixClient;
packs: ImagePack[];
usage: PackUsage;
onItemClick: (id: string) => void;
}) {
const activeGroupId = useAtomValue(activeGroupIdAtom);
return (
<SidebarStack>
{usage === PackUsage.Emoticon && <SidebarDivider />}
{packs.map((pack) => {
let label = pack.displayName;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
return (
<SidebarBtn
active={activeGroupId === pack.id}
key={pack.id}
id={pack.id}
label={label || 'Unknown Pack'}
onItemClick={onItemClick}
>
<img
style={{
width: toRem(24),
height: toRem(24),
objectFit: 'contain',
}}
src={mx.mxcUrlToHttp(pack.getPackAvatarUrl(usage) ?? '') || pack.avatarUrl}
alt={label || 'Unknown Pack'}
/>
</SidebarBtn>
);
})}
</SidebarStack>
);
}
function NativeEmojiSidebarStack({
groups,
icons,
labels,
onItemClick,
}: {
groups: IEmojiGroup[];
icons: IEmojiGroupIcons;
labels: IEmojiGroupLabels;
onItemClick: (id: EmojiGroupId) => void;
}) {
const activeGroupId = useAtomValue(activeGroupIdAtom);
return (
<SidebarStack className={css.NativeEmojiSidebarStack}>
<SidebarDivider />
{groups.map((group) => (
<SidebarBtn
key={group.id}
active={activeGroupId === group.id}
id={group.id}
label={labels[group.id]}
onItemClick={onItemClick}
>
<Icon src={icons[group.id]} filled={activeGroupId === group.id} />
</SidebarBtn>
))}
</SidebarStack>
);
}
export function RecentEmojiGroup({
label,
id,
emojis: recentEmojis,
}: {
label: string;
id: string;
emojis: IEmoji[];
}) {
return (
<EmojiGroup key={id} id={id} label={label}>
{recentEmojis.map((emoji) => (
<EmojiItem
key={emoji.unicode}
label={emoji.label}
type={EmojiType.Emoji}
data={emoji.unicode}
shortcode={emoji.shortcode}
>
{emoji.unicode}
</EmojiItem>
))}
</EmojiGroup>
);
}
export function SearchEmojiGroup({
mx,
tab,
label,
id,
emojis: searchResult,
}: {
mx: MatrixClient;
tab: EmojiBoardTab;
label: string;
id: string;
emojis: Array<ExtendedPackImage | IEmoji>;
}) {
return (
<EmojiGroup key={id} id={id} label={label}>
{tab === EmojiBoardTab.Emoji
? searchResult.map((emoji) =>
'unicode' in emoji ? (
<EmojiItem
key={emoji.unicode}
label={emoji.label}
type={EmojiType.Emoji}
data={emoji.unicode}
shortcode={emoji.shortcode}
>
{emoji.unicode}
</EmojiItem>
) : (
<EmojiItem
key={emoji.shortcode}
label={emoji.body || emoji.shortcode}
type={EmojiType.CustomEmoji}
data={emoji.url}
shortcode={emoji.shortcode}
>
<img
loading="lazy"
className={css.CustomEmojiImg}
alt={emoji.body || emoji.shortcode}
src={mx.mxcUrlToHttp(emoji.url) ?? emoji.url}
/>
</EmojiItem>
)
)
: searchResult.map((emoji) =>
'unicode' in emoji ? null : (
<StickerItem
key={emoji.shortcode}
label={emoji.body || emoji.shortcode}
type={EmojiType.Sticker}
data={emoji.url}
shortcode={emoji.shortcode}
>
<img
loading="lazy"
className={css.StickerImg}
alt={emoji.body || emoji.shortcode}
src={mx.mxcUrlToHttp(emoji.url) ?? emoji.url}
/>
</StickerItem>
)
)}
</EmojiGroup>
);
}
export const CustomEmojiGroups = memo(
({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
<>
{groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
{pack.getEmojis().map((image) => (
<EmojiItem
key={image.shortcode}
label={image.body || image.shortcode}
type={EmojiType.CustomEmoji}
data={image.url}
shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
src={mx.mxcUrlToHttp(image.url) ?? image.url}
/>
</EmojiItem>
))}
</EmojiGroup>
))}
</>
)
);
export const StickerGroups = memo(({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
<>
{groups.length === 0 && (
<Box
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="300"
>
<Icon size="600" src={Icons.Sticker} />
<Box direction="Inherit">
<Text align="Center">No Sticker Packs!</Text>
<Text priority="300" align="Center" size="T200">
Add stickers from user, room or space settings.
</Text>
</Box>
</Box>
)}
{groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
{pack.getStickers().map((image) => (
<StickerItem
key={image.shortcode}
label={image.body || image.shortcode}
type={EmojiType.Sticker}
data={image.url}
shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.StickerImg}
alt={image.body || image.shortcode}
src={mx.mxcUrlToHttp(image.url) ?? image.url}
/>
</StickerItem>
))}
</EmojiGroup>
))}
</>
));
export const NativeEmojiGroups = memo(
({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => (
<>
{groups.map((emojiGroup) => (
<EmojiGroup key={emojiGroup.id} id={emojiGroup.id} label={labels[emojiGroup.id]}>
{emojiGroup.emojis.map((emoji) => (
<EmojiItem
key={emoji.unicode}
label={emoji.label}
type={EmojiType.Emoji}
data={emoji.unicode}
shortcode={emoji.shortcode}
>
{emoji.unicode}
</EmojiItem>
))}
</EmojiGroup>
))}
</>
)
);
const getSearchListItemStr = (item: ExtendedPackImage | IEmoji) => {
const shortcode = `:${item.shortcode}:`;
if ('body' in item) {
return [shortcode, item.body ?? ''];
}
return shortcode;
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 26,
matchOptions: {
contain: true,
},
};
export function EmojiBoard({
tab = EmojiBoardTab.Emoji,
onTabChange,
imagePackRooms,
requestClose,
returnFocusOnDeactivate,
onEmojiSelect,
onCustomEmojiSelect,
onStickerSelect,
allowTextCustomEmoji,
}: {
tab?: EmojiBoardTab;
onTabChange?: (tab: EmojiBoardTab) => void;
imagePackRooms: Room[];
requestClose: () => void;
returnFocusOnDeactivate?: boolean;
onEmojiSelect?: (unicode: string, shortcode: string) => void;
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
allowTextCustomEmoji?: boolean;
}) {
const emojiTab = tab === EmojiBoardTab.Emoji;
const stickerTab = tab === EmojiBoardTab.Sticker;
const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker;
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const mx = useMatrixClient();
const emojiGroupLabels = useEmojiGroupLabels();
const emojiGroupIcons = useEmojiGroupIcons();
const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms);
const recentEmojis = useRecentEmoji(mx, 21);
const contentScrollRef = useRef<HTMLDivElement>(null);
const emojiPreviewRef = useRef<HTMLDivElement>(null);
const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
const searchList = useMemo(() => {
let list: Array<ExtendedPackImage | IEmoji> = [];
list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage)));
if (emojiTab) list = list.concat(emojis);
return list;
}, [emojiTab, usage, imagePacks]);
const [result, search, resetSearch] = useAsyncSearch(
searchList,
getSearchListItemStr,
SEARCH_OPTIONS
);
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
useCallback(
(evt) => {
const term = evt.target.value;
if (term) search(term);
else resetSearch();
},
[search, resetSearch]
),
{ wait: 200 }
);
const syncActiveGroupId = useCallback(() => {
const targetEl = contentScrollRef.current;
if (!targetEl) return;
const groupEls = [...targetEl.querySelectorAll('div[data-group-id]')] as HTMLElement[];
const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
setActiveGroupId(groupId);
}, [setActiveGroupId]);
const handleOnScroll: UIEventHandler<HTMLDivElement> = useThrottle(syncActiveGroupId, {
wait: 500,
});
const handleScrollToGroup = (groupId: string) => {
setActiveGroupId(groupId);
const groupElement = document.getElementById(getDOMGroupId(groupId));
groupElement?.scrollIntoView();
};
const handleEmojiClick: MouseEventHandler = (evt) => {
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
if (!targetEl) return;
const emojiInfo = getEmojiItemInfo(targetEl);
if (!emojiInfo) return;
if (emojiInfo.type === EmojiType.Emoji) {
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey) {
addRecentEmoji(mx, emojiInfo.data);
requestClose();
}
}
if (emojiInfo.type === EmojiType.CustomEmoji) {
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey) requestClose();
}
if (emojiInfo.type === EmojiType.Sticker) {
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
if (!evt.altKey && !evt.shiftKey) requestClose();
}
};
const handleEmojiPreview = useCallback(
(element: HTMLButtonElement) => {
const emojiInfo = getEmojiItemInfo(element);
if (!emojiInfo || !emojiPreviewTextRef.current) return;
if (emojiInfo.type === EmojiType.Emoji && emojiPreviewRef.current) {
emojiPreviewRef.current.textContent = emojiInfo.data;
} else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
const img = document.createElement('img');
img.className = css.CustomEmojiImg;
img.setAttribute('src', mx.mxcUrlToHttp(emojiInfo.data) || emojiInfo.data);
img.setAttribute('alt', emojiInfo.shortcode);
emojiPreviewRef.current.textContent = '';
emojiPreviewRef.current.appendChild(img);
}
emojiPreviewTextRef.current.textContent = `:${emojiInfo.shortcode}:`;
},
[mx]
);
const throttleEmojiHover = useThrottle(handleEmojiPreview, {
wait: 200,
immediate: true,
});
const handleEmojiHover: MouseEventHandler = (evt) => {
const targetEl = targetFromEvent(evt.nativeEvent, 'button') as HTMLButtonElement | undefined;
if (!targetEl) return;
throttleEmojiHover(targetEl);
};
const handleEmojiFocus: FocusEventHandler = (evt) => {
const targetEl = evt.target as HTMLButtonElement;
handleEmojiPreview(targetEl);
};
// Reset scroll top on search and tab change
useEffect(() => {
syncActiveGroupId();
contentScrollRef.current?.scrollTo({
top: 0,
});
}, [result, emojiTab, syncActiveGroupId]);
return (
<FocusTrap
focusTrapOptions={{
returnFocusOnDeactivate,
initialFocus: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
allowOutsideClick: true,
isKeyForward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
isKeyBackward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
}}
>
<EmojiBoardLayout
header={
<Header>
<Box direction="Column" gap="200">
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
<Input
data-emoji-board-search
variant="SurfaceVariant"
size="400"
placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'}
maxLength={50}
after={
allowTextCustomEmoji && result?.query ? (
<Chip
variant="Primary"
radii="Pill"
after={<Icon src={Icons.ArrowRight} size="50" />}
outlined
onClick={() => {
const searchInput = document.querySelector<HTMLInputElement>(
'[data-emoji-board-search="true"]'
);
const textReaction = searchInput?.value.trim();
if (!textReaction) return;
onCustomEmojiSelect?.(textReaction, textReaction);
requestClose();
}}
>
<Text size="L400">React</Text>
</Chip>
) : (
<Icon src={Icons.Search} size="50" />
)
}
onChange={handleOnChange}
autoFocus={!mobileOrTablet()}
/>
</Box>
</Header>
}
sidebar={
<Sidebar>
{emojiTab && recentEmojis.length > 0 && (
<RecentEmojiSidebarStack onItemClick={handleScrollToGroup} />
)}
{imagePacks.length > 0 && (
<ImagePackSidebarStack
mx={mx}
usage={usage}
packs={imagePacks}
onItemClick={handleScrollToGroup}
/>
)}
{emojiTab && (
<NativeEmojiSidebarStack
groups={emojiGroups}
icons={emojiGroupIcons}
labels={emojiGroupLabels}
onItemClick={handleScrollToGroup}
/>
)}
</Sidebar>
}
footer={
emojiTab ? (
<Footer>
<Box
display="InlineFlex"
ref={emojiPreviewRef}
className={css.EmojiPreview}
alignItems="Center"
justifyContent="Center"
>
😃
</Box>
<Text ref={emojiPreviewTextRef} size="H5" truncate>
:smiley:
</Text>
</Footer>
) : (
imagePacks.length > 0 && (
<Footer>
<Text ref={emojiPreviewTextRef} size="H5" truncate>
:smiley:
</Text>
</Footer>
)
)
}
>
<Content>
<Scroll
ref={contentScrollRef}
size="400"
onScroll={handleOnScroll}
onKeyDown={preventScrollWithArrowKey}
hideTrack
>
<Box
onClick={handleEmojiClick}
onMouseMove={handleEmojiHover}
onFocus={handleEmojiFocus}
direction="Column"
gap="200"
>
{result && (
<SearchEmojiGroup
mx={mx}
tab={tab}
id={SEARCH_GROUP_ID}
label={result.items.length ? 'Search Results' : 'No Results found'}
emojis={result.items}
/>
)}
{emojiTab && recentEmojis.length > 0 && (
<RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
)}
{emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} />}
{stickerTab && <StickerGroups mx={mx} groups={imagePacks} />}
{emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
</Box>
</Scroll>
</Content>
</EmojiBoardLayout>
</FocusTrap>
);
}

View file

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

View file

@ -1,21 +0,0 @@
import { useMemo } from 'react';
import { IconSrc, Icons } from 'folds';
import { EmojiGroupId } from '../../plugins/emoji';
export type IEmojiGroupIcons = Record<EmojiGroupId, IconSrc>;
export const useEmojiGroupIcons = (): IEmojiGroupIcons =>
useMemo(
() => ({
[EmojiGroupId.People]: Icons.Smile,
[EmojiGroupId.Nature]: Icons.Leaf,
[EmojiGroupId.Food]: Icons.Cup,
[EmojiGroupId.Activity]: Icons.Ball,
[EmojiGroupId.Travel]: Icons.Photo,
[EmojiGroupId.Object]: Icons.Bulb,
[EmojiGroupId.Symbol]: Icons.Peace,
[EmojiGroupId.Flag]: Icons.Flag,
}),
[]
);

View file

@ -1,19 +0,0 @@
import { useMemo } from 'react';
import { EmojiGroupId } from '../../plugins/emoji';
export type IEmojiGroupLabels = Record<EmojiGroupId, string>;
export const useEmojiGroupLabels = (): IEmojiGroupLabels =>
useMemo(
() => ({
[EmojiGroupId.People]: 'Smileys & People',
[EmojiGroupId.Nature]: 'Animals & Nature',
[EmojiGroupId.Food]: 'Food & Drinks',
[EmojiGroupId.Activity]: 'Activity',
[EmojiGroupId.Travel]: 'Travel & Places',
[EmojiGroupId.Object]: 'Objects',
[EmojiGroupId.Symbol]: 'Symbols',
[EmojiGroupId.Flag]: 'Flags',
}),
[]
);

View file

@ -1,21 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
export const EventReaders = style([
DefaultReset,
{
height: '100%',
},
]);
export const Header = style({
paddingLeft: config.space.S400,
paddingRight: config.space.S300,
flexShrink: 0,
});
export const Content = style({
paddingLeft: config.space.S200,
paddingBottom: config.space.S400,
});

View file

@ -1,102 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import {
Avatar,
AvatarFallback,
AvatarImage,
Box,
Header,
Icon,
IconButton,
Icons,
MenuItem,
Scroll,
Text,
as,
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import colorMXID from '../../../util/colorMXID';
import { openProfileViewer } from '../../../client/action/navigation';
export type EventReadersProps = {
room: Room;
eventId: string;
requestClose: () => void;
};
export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const latestEventReaders = useRoomEventReaders(room, eventId);
const getName = (userId: string) =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
return (
<Box
className={classNames(css.EventReaders, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.Header} variant="Surface" size="600">
<Box grow="Yes">
<Text size="H3">Seen by</Text>
</Box>
<IconButton size="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column">
{latestEventReaders.map((readerId) => {
const name = getName(readerId);
const avatarUrl = room
.getMember(readerId)
?.getAvatarUrl(mx.baseUrl, 100, 100, 'crop', undefined, false);
return (
<MenuItem
key={readerId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={() => {
requestClose();
openProfileViewer(readerId, room.roomId);
}}
before={
<Avatar size="200">
{avatarUrl ? (
<AvatarImage src={avatarUrl} />
) : (
<AvatarFallback
style={{
background: colorMXID(readerId),
color: 'white',
}}
>
<Text size="H6">{name[0]}</Text>
</AvatarFallback>
)}
</Avatar>
}
>
<Text size="T400" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Box>
);
}
);

View file

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

View file

@ -1,42 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
export const ImageViewer = style([
DefaultReset,
{
height: '100%',
},
]);
export const ImageViewerHeader = style([
DefaultReset,
{
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
},
]);
export const ImageViewerContent = style([
DefaultReset,
{
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
overflow: 'hidden',
},
]);
export const ImageViewerImg = style([
DefaultReset,
{
objectFit: 'contain',
width: 'auto',
height: 'auto',
maxWidth: '100%',
maxHeight: '100%',
backgroundColor: color.Surface.Container,
transition: 'transform 100ms linear',
},
]);

View file

@ -1,95 +0,0 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
export type ImageViewerProps = {
alt: string;
src: string;
requestClose: () => void;
};
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = () => {
FileSaver.saveAs(src, alt);
};
return (
<Box
className={classNames(css.ImageViewer, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{alt}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.ImageViewerContent}
justifyContent="Center"
alignItems="Center"
>
<img
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
}}
src={src}
alt={alt}
onMouseDown={onMouseDown}
/>
</Box>
</Box>
);
}
);

View file

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

View file

@ -1,9 +0,0 @@
import React, { ImgHTMLAttributes, forwardRef } from 'react';
import classNames from 'classnames';
import * as css from './media.css';
export const Image = forwardRef<HTMLImageElement, ImgHTMLAttributes<HTMLImageElement>>(
({ className, alt, ...props }, ref) => (
<img className={classNames(css.Image, className)} alt={alt} {...props} ref={ref} />
)
);

View file

@ -1,27 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, as } from 'folds';
export type MediaControlProps = {
before?: ReactNode;
after?: ReactNode;
leftControl?: ReactNode;
rightControl?: ReactNode;
};
export const MediaControl = as<'div', MediaControlProps>(
({ before, after, leftControl, rightControl, children, ...props }, ref) => (
<Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
{before && <Box direction="Column">{before}</Box>}
<Box alignItems="Center" gap="200">
<Box alignItems="Center" grow="Yes" gap="Inherit">
{leftControl}
</Box>
<Box justifyItems="End" alignItems="Center" gap="Inherit">
{rightControl}
</Box>
</Box>
{after && <Box direction="Column">{after}</Box>}
{children}
</Box>
)
);

View file

@ -1,10 +0,0 @@
import React, { VideoHTMLAttributes, forwardRef } from 'react';
import classNames from 'classnames';
import * as css from './media.css';
export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
({ className, ...props }, ref) => (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video className={classNames(css.Video, className)} {...props} ref={ref} />
)
);

View file

@ -1,3 +0,0 @@
export * from './Image';
export * from './Video';
export * from './MediaControls';

View file

@ -1,20 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset } from 'folds';
export const Image = style([
DefaultReset,
{
objectFit: 'cover',
width: '100%',
height: '100%',
},
]);
export const Video = style([
DefaultReset,
{
objectFit: 'contain',
width: '100%',
height: '100%',
},
]);

View file

@ -1,66 +0,0 @@
import { Box, Icon, Icons, Text, as, color, config } from 'folds';
import React from 'react';
const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
({ reason, ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Delete} />
{reason ? (
<i>This message has been deleted. {reason}</i>
) : (
<i>This message has been deleted</i>
)}
</Box>
)
);
export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} />
<i>Unsupported message</i>
</Box>
));
export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} />
<i>Failed to load message</i>
</Box>
));
export const MessageBadEncryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Lock} />
<i>Unable to decrypt message</i>
</Box>
));
export const MessageNotDecryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Lock} />
<i>This message is not decrypted yet</i>
</Box>
));
export const MessageBrokenContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} />
<i>Broken message</i>
</Box>
));
export const MessageEmptyContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} />
<i>Empty message</i>
</Box>
));
export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => (
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
{' (edited)'}
</Text>
));

View file

@ -1,75 +0,0 @@
import { createVar, style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
const Container = createVar();
const ContainerHover = createVar();
const ContainerActive = createVar();
const ContainerLine = createVar();
const OnContainer = createVar();
export const Reaction = style([
FocusOutline,
{
vars: {
[Container]: color.SurfaceVariant.Container,
[ContainerHover]: color.SurfaceVariant.ContainerHover,
[ContainerActive]: color.SurfaceVariant.ContainerActive,
[ContainerLine]: color.SurfaceVariant.ContainerLine,
[OnContainer]: color.SurfaceVariant.OnContainer,
},
padding: `${toRem(2)} ${config.space.S200} ${toRem(2)} ${config.space.S100}`,
backgroundColor: Container,
border: `${config.borderWidth.B300} solid ${ContainerLine}`,
borderRadius: config.radii.R300,
selectors: {
'button&': {
cursor: 'pointer',
},
'&[aria-pressed=true]': {
vars: {
[Container]: color.Primary.Container,
[ContainerHover]: color.Primary.ContainerHover,
[ContainerActive]: color.Primary.ContainerActive,
[ContainerLine]: color.Primary.ContainerLine,
[OnContainer]: color.Primary.OnContainer,
},
backgroundColor: Container,
},
'&[aria-selected=true]': {
borderColor: color.Secondary.Main,
borderWidth: config.borderWidth.B400,
},
'&:hover, &:focus-visible': {
backgroundColor: ContainerHover,
},
'&:active': {
backgroundColor: ContainerActive,
},
'&[aria-disabled=true], &:disabled': {
cursor: 'not-allowed',
},
},
},
]);
export const ReactionText = style([
DefaultReset,
{
minWidth: 0,
maxWidth: toRem(150),
display: 'inline-flex',
alignItems: 'center',
lineHeight: toRem(20),
},
]);
export const ReactionImg = style([
DefaultReset,
{
height: '1em',
minWidth: 0,
maxWidth: toRem(150),
objectFit: 'contain',
},
]);

View file

@ -1,113 +0,0 @@
import React from 'react';
import { Box, Text, as } from 'folds';
import classNames from 'classnames';
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart } from '../../utils/matrix';
export const Reaction = as<
'button',
{
mx: MatrixClient;
count: number;
reaction: string;
}
>(({ className, mx, count, reaction, ...props }, ref) => (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{reaction.startsWith('mxc://') ? (
<img
className={css.ReactionImg}
src={mx.mxcUrlToHttp(reaction) ?? reaction}
alt={reaction}
/>
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
)}
</Text>
<Text as="span" size="T300">
{count}
</Text>
</Box>
));
type ReactionTooltipMsgProps = {
room: Room;
reaction: string;
events: MatrixEvent[];
};
export function ReactionTooltipMsg({ room, reaction, events }: ReactionTooltipMsgProps) {
const shortCodeEvt = events.find(eventWithShortcode);
const shortcode =
shortCodeEvt?.getContent().shortcode ??
getShortcodeFor(getHexcodeForEmoji(reaction)) ??
reaction;
const names = events.map(
(ev: MatrixEvent) =>
getMemberDisplayName(room, ev.getSender() ?? 'Unknown') ??
getMxIdLocalPart(ev.getSender() ?? 'Unknown') ??
'Unknown'
);
return (
<>
{names.length === 1 && <b>{names[0]}</b>}
{names.length === 2 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[1]}</b>
</>
)}
{names.length === 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[2]}</b>
</>
)}
{names.length > 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names.length - 3} others</b>
</>
)}
<Text as="span" size="Inherit" priority="300">
{' reacted with '}
</Text>
:<b>{shortcode}</b>:
</>
);
}

View file

@ -1,25 +0,0 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const Reply = style({
padding: `0 ${config.space.S100}`,
marginBottom: toRem(1),
cursor: 'pointer',
minWidth: 0,
maxWidth: '100%',
minHeight: config.lineHeight.T300,
});
export const ReplyContent = style({
opacity: config.opacity.P300,
selectors: {
[`${Reply}:hover &`]: {
opacity: config.opacity.P500,
},
},
});
export const ReplyContentText = style({
paddingRight: config.space.S100,
});

View file

@ -1,103 +0,0 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import React, { useEffect, useState } from 'react';
import to from 'await-to-js';
import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css';
import {
MessageBadEncryptedContent,
MessageDeletedContent,
MessageFailedContent,
} from './MessageContentFallback';
type ReplyProps = {
mx: MatrixClient;
room: Room;
timelineSet: EventTimelineSet;
eventId: string;
};
export const Reply = as<'div', ReplyProps>(
({ className, mx, room, timelineSet, eventId, ...props }, ref) => {
const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
timelineSet.findEventById(eventId)
);
const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender();
const fallbackBody = replyEvent?.isRedacted() ? (
<MessageDeletedContent />
) : (
<MessageFailedContent />
);
useEffect(() => {
let disposed = false;
const loadEvent = async () => {
const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
const mEvent = new MatrixEvent(evt);
if (disposed) return;
if (err) {
setReplyEvent(null);
return;
}
if (mEvent.isEncrypted() && mx.getCrypto()) {
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
}
setReplyEvent(mEvent);
};
if (replyEvent === undefined) loadEvent();
return () => {
disposed = true;
};
}, [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)}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Box
style={{ color: colorMXID(sender ?? eventId), maxWidth: '50%' }}
alignItems="Center"
shrink="No"
>
<Icon src={Icons.ReplyArrow} size="50" />
{sender && (
<Text size="T300" truncate>
{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
</Text>
)}
</Box>
<Box grow="Yes" className={css.ReplyContent}>
{replyEvent !== undefined ? (
<Text className={css.ReplyContentText} size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
) : (
<LinePlaceholder
style={{
backgroundColor: color.SurfaceVariant.ContainerActive,
maxWidth: toRem(randomNumberBetween(40, 400)),
width: '100%',
}}
/>
)}
</Box>
</Box>
);
}
);

View file

@ -1,27 +0,0 @@
import React from 'react';
import { Text, as } from 'folds';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
export type TimeProps = {
compact?: boolean;
ts: number;
};
export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
let time = '';
if (compact) {
time = timeHourMinute(ts);
} else if (today(ts)) {
time = timeHourMinute(ts);
} else if (yesterday(ts)) {
time = `Yesterday ${timeHourMinute(ts)}`;
} else {
time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
}
return (
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
{time}
</Text>
);
});

View file

@ -1,42 +0,0 @@
import { style } from '@vanilla-extract/css';
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const Attachment = recipe({
base: {
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400,
overflow: 'hidden',
maxWidth: '100%',
width: toRem(400),
},
variants: {
outlined: {
true: {
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
},
},
},
});
export type AttachmentVariants = RecipeVariants<typeof Attachment>;
export const AttachmentHeader = style({
padding: config.space.S300,
});
export const AttachmentBox = style([
DefaultReset,
{
maxWidth: '100%',
maxHeight: toRem(600),
width: toRem(400),
overflow: 'hidden',
},
]);
export const AttachmentContent = style({
padding: config.space.S300,
paddingTop: 0,
});

View file

@ -1,44 +0,0 @@
import React from 'react';
import { Box, as } from 'folds';
import classNames from 'classnames';
import * as css from './Attachment.css';
export const Attachment = as<'div', css.AttachmentVariants>(
({ className, outlined, ...props }, ref) => (
<Box
display="InlineFlex"
direction="Column"
className={classNames(css.Attachment({ outlined }), className)}
{...props}
ref={ref}
/>
)
);
export const AttachmentHeader = as<'div'>(({ className, ...props }, ref) => (
<Box
shrink="No"
gap="200"
className={classNames(css.AttachmentHeader, className)}
{...props}
ref={ref}
/>
));
export const AttachmentBox = as<'div'>(({ className, ...props }, ref) => (
<Box
direction="Column"
className={classNames(css.AttachmentBox, className)}
{...props}
ref={ref}
/>
));
export const AttachmentContent = as<'div'>(({ className, ...props }, ref) => (
<Box
direction="Column"
className={classNames(css.AttachmentContent, className)}
{...props}
ref={ref}
/>
));

View file

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

View file

@ -1,7 +0,0 @@
export * from './layout';
export * from './placeholder';
export * from './Reaction';
export * from './attachment';
export * from './Reply';
export * from './MessageContentFallback';
export * from './Time';

View file

@ -1,38 +0,0 @@
import React from 'react';
import { Text, as } from 'folds';
import classNames from 'classnames';
import * as css from './layout.css';
export const MessageBase = as<'div', css.MessageBaseVariants>(
({ className, highlight, selected, collapse, autoCollapse, space, ...props }, ref) => (
<div
className={classNames(
css.MessageBase({ highlight, selected, collapse, autoCollapse, space }),
className
)}
{...props}
ref={ref}
/>
)
);
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
<span className={classNames(css.AvatarBase, className)} {...props} ref={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

@ -1,18 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, as } from 'folds';
import * as css from './layout.css';
type BubbleLayoutProps = {
before?: ReactNode;
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => (
<Box gap="300" {...props} ref={ref}>
<Box className={css.BubbleBefore} shrink="No">
{before}
</Box>
<Box className={css.BubbleContent} direction="Column">
{children}
</Box>
</Box>
));

View file

@ -1,18 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, as } from 'folds';
import * as css from './layout.css';
type CompactLayoutProps = {
before?: ReactNode;
};
export const CompactLayout = as<'div', CompactLayoutProps>(
({ before, children, ...props }, ref) => (
<Box gap="200" {...props} ref={ref}>
<Box className={css.CompactHeader} gap="200" shrink="No">
{before}
</Box>
{children}
</Box>
)
);

View file

@ -1,18 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, as } from 'folds';
import * as css from './layout.css';
type ModernLayoutProps = {
before?: ReactNode;
};
export const ModernLayout = as<'div', ModernLayoutProps>(({ before, children, ...props }, ref) => (
<Box gap="300" {...props} ref={ref}>
<Box className={css.ModernBefore} shrink="No">
{before}
</Box>
<Box grow="Yes" direction="Column">
{children}
</Box>
</Box>
));

View file

@ -1,4 +0,0 @@
export * from './Modern';
export * from './Compact';
export * from './Bubble';
export * from './Base';

View file

@ -1,182 +0,0 @@
import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const StickySection = style({
position: 'sticky',
top: config.space.S100,
});
const SpacingVar = createVar();
const SpacingVariant = styleVariants({
'0': {
vars: {
[SpacingVar]: config.space.S0,
},
},
'100': {
vars: {
[SpacingVar]: config.space.S100,
},
},
'200': {
vars: {
[SpacingVar]: config.space.S200,
},
},
'300': {
vars: {
[SpacingVar]: config.space.S300,
},
},
'400': {
vars: {
[SpacingVar]: config.space.S400,
},
},
'500': {
vars: {
[SpacingVar]: config.space.S500,
},
},
});
const highlightAnime = keyframes({
'0%': {
backgroundColor: color.Primary.Container,
},
'25%': {
backgroundColor: color.Primary.ContainerActive,
},
'50%': {
backgroundColor: color.Primary.Container,
},
'75%': {
backgroundColor: color.Primary.ContainerActive,
},
'100%': {
backgroundColor: color.Primary.Container,
},
});
const HighlightVariant = styleVariants({
true: {
animation: `${highlightAnime} 2000ms ease-in-out`,
},
});
const SelectedVariant = styleVariants({
true: {
backgroundColor: color.Surface.ContainerActive,
},
});
const AutoCollapse = style({
selectors: {
[`&+&`]: {
marginTop: 0,
},
},
});
export const MessageBase = recipe({
base: [
DefaultReset,
{
marginTop: SpacingVar,
padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`,
borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`,
},
],
variants: {
space: SpacingVariant,
collapse: {
true: {
marginTop: 0,
},
},
autoCollapse: {
true: AutoCollapse,
},
highlight: HighlightVariant,
selected: SelectedVariant,
},
defaultVariants: {
space: '400',
},
});
export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
export const CompactHeader = style([
DefaultReset,
StickySection,
{
maxWidth: toRem(170),
width: '100%',
},
]);
export const AvatarBase = style({
paddingTop: toRem(4),
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
alignSelf: 'start',
selectors: {
'&:hover': {
transform: `translateY(${toRem(-4)})`,
},
},
});
export const ModernBefore = style({
minWidth: toRem(36),
});
export const BubbleBefore = style([ModernBefore]);
export const BubbleContent = style({
maxWidth: toRem(800),
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400,
});
export const Username = style({
cursor: 'pointer',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
selectors: {
'&:hover, &:focus-visible': {
textDecoration: 'underline',
},
},
});
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

@ -1,22 +0,0 @@
import React from 'react';
import { as, toRem } from 'folds';
import { randomNumberBetween } from '../../../utils/common';
import { LinePlaceholder } from './LinePlaceholder';
import { CompactLayout, MessageBase } from '../layout';
export const CompactPlaceholder = as<'div'>(({ ...props }, ref) => (
<MessageBase>
<CompactLayout
{...props}
ref={ref}
before={
<>
<LinePlaceholder style={{ maxWidth: toRem(50) }} />
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
</>
}
>
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(120, 500)) }} />
</CompactLayout>
</MessageBase>
));

View file

@ -1,25 +0,0 @@
import React, { CSSProperties } from 'react';
import { Avatar, Box, as, color, toRem } from 'folds';
import { randomNumberBetween } from '../../../utils/common';
import { LinePlaceholder } from './LinePlaceholder';
import { MessageBase, ModernLayout } from '../layout';
const contentMargin: CSSProperties = { marginTop: toRem(3) };
const avatarBg: CSSProperties = { backgroundColor: color.SurfaceVariant.Container };
export const DefaultPlaceholder = as<'div'>(({ ...props }, ref) => (
<MessageBase>
<ModernLayout {...props} ref={ref} before={<Avatar style={avatarBg} size="300" />}>
<Box style={contentMargin} grow="Yes" direction="Column" gap="200">
<Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
<LinePlaceholder style={{ maxWidth: toRem(50) }} />
</Box>
<Box grow="Yes" gap="200" wrap="Wrap">
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
</Box>
</Box>
</ModernLayout>
</MessageBase>
));

View file

@ -1,12 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const LinePlaceholder = style([
DefaultReset,
{
width: '100%',
height: toRem(16),
borderRadius: config.radii.R300,
backgroundColor: color.SurfaceVariant.Container,
},
]);

View file

@ -1,8 +0,0 @@
import React from 'react';
import { Box, as } from 'folds';
import classNames from 'classnames';
import * as css from './LinePlaceholder.css';
export const LinePlaceholder = as<'div'>(({ className, ...props }, ref) => (
<Box className={classNames(css.LinePlaceholder, className)} shrink="No" {...props} ref={ref} />
));

View file

@ -1,3 +0,0 @@
export * from './LinePlaceholder';
export * from './CompactPlaceholder';
export * from './DefaultPlaceholder';

View file

@ -1,114 +0,0 @@
import React, { useCallback } from 'react';
import { Avatar, AvatarFallback, AvatarImage, Box, Button, Spinner, Text, as, color } from 'folds';
import { Room } from 'matrix-js-sdk';
import { openInviteUser, selectRoom } from '../../../client/action/navigation';
import { useStateEvent } from '../../hooks/useStateEvent';
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart } from '../../utils/matrix';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
export type RoomIntroProps = {
room: Room;
};
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
const mx = useMatrixClient();
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
const nameEvent = useStateEvent(room, StateEvent.RoomName);
const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
const createContent = createEvent?.getContent<IRoomCreateContent>();
const ts = createEvent?.getTs();
const creatorId = createEvent?.getSender();
const creatorName =
creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
const prevRoomId = createContent?.predecessor?.room_id;
const avatarMxc = (avatarEvent?.getContent().url as string) || undefined;
const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
const name = (nameEvent?.getContent().name || room.name) as string;
const topic = (topicEvent?.getContent().topic as string) || undefined;
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
);
return (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box>
<Avatar size="500">
{avatarHttpUrl ? (
<AvatarImage src={avatarHttpUrl} alt={name} />
) : (
<AvatarFallback
style={{
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
<Text size="H2">{name[0]}</Text>
</AvatarFallback>
)}
</Avatar>
</Box>
<Box direction="Column" gap="300">
<Box direction="Column" gap="100">
<Text size="H3" priority="500">
{name}
</Text>
<Text size="T400" priority="400">
{typeof topic === 'string' ? topic : 'This is the beginning of conversation.'}
</Text>
{creatorName && ts && (
<Text size="T200" priority="300">
{'Created by '}
<b>@{creatorName}</b>
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`}
</Text>
)}
</Box>
<Box gap="200" wrap="Wrap">
<Button
onClick={() => openInviteUser(room.roomId)}
variant="Secondary"
size="300"
radii="300"
>
<Text size="B300">Invite Member</Text>
</Button>
{typeof prevRoomId === 'string' &&
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
<Button
onClick={() => selectRoom(prevRoomId)}
variant="Success"
size="300"
fill="Soft"
radii="300"
>
<Text size="B300">Open Old Room</Text>
</Button>
) : (
<Button
onClick={() => joinPrevRoom(prevRoomId)}
variant="Secondary"
size="300"
fill="Soft"
radii="300"
disabled={prevRoomState.status === AsyncStatus.Loading}
after={
prevRoomState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Secondary" fill="Soft" />
) : undefined
}
>
<Text size="B300">Join Old Room</Text>
</Button>
))}
</Box>
</Box>
</Box>
);
});

View file

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

View file

@ -1,111 +0,0 @@
import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { color, config, DefaultReset, toRem } from 'folds';
export const Sidebar = style([
DefaultReset,
{
width: toRem(66),
backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
display: 'flex',
flexDirection: 'column',
color: color.Background.OnContainer,
},
]);
export const SidebarStack = style([
DefaultReset,
{
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: config.space.S300,
padding: `${config.space.S300} 0`,
},
]);
const PUSH_X = 2;
export const SidebarAvatarBox = recipe({
base: [
DefaultReset,
{
display: 'flex',
alignItems: 'center',
position: 'relative',
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
selectors: {
'&:hover': {
transform: `translateX(${toRem(PUSH_X)})`,
},
'&::before': {
content: '',
display: 'none',
position: 'absolute',
left: toRem(-11.5 - PUSH_X),
width: toRem(3 + PUSH_X),
height: toRem(16),
borderRadius: `0 ${toRem(4)} ${toRem(4)} 0`,
background: 'CurrentColor',
transition: 'height 200ms linear',
},
'&:hover::before': {
display: 'block',
width: toRem(3),
},
},
},
],
variants: {
active: {
true: {
selectors: {
'&::before': {
display: 'block',
height: toRem(24),
},
'&:hover::before': {
width: toRem(3 + PUSH_X),
},
},
},
},
},
});
export type SidebarAvatarBoxVariants = RecipeVariants<typeof SidebarAvatarBox>;
export const SidebarBadgeBox = recipe({
base: [
DefaultReset,
{
position: 'absolute',
zIndex: 1,
},
],
variants: {
hasCount: {
true: {
top: toRem(-6),
right: toRem(-6),
},
false: {
top: toRem(-2),
right: toRem(-2),
},
},
},
defaultVariants: {
hasCount: false,
},
});
export type SidebarBadgeBoxVariants = RecipeVariants<typeof SidebarBadgeBox>;
export const SidebarBadgeOutline = style({
boxShadow: `0 0 0 ${config.borderWidth.B500} ${color.Background.Container}`,
});

View file

@ -1,8 +0,0 @@
import classNames from 'classnames';
import { as } from 'folds';
import React from 'react';
import * as css from './Sidebar.css';
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
));

View file

@ -1,75 +0,0 @@
import classNames from 'classnames';
import { as, Avatar, Box, color, config, Text, Tooltip, TooltipProvider } from 'folds';
import React, { forwardRef, MouseEventHandler, ReactNode } from 'react';
import * as css from './Sidebar.css';
const SidebarAvatarBox = as<'div', css.SidebarAvatarBoxVariants>(
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
<AsSidebarAvatarBox
className={classNames(css.SidebarAvatarBox({ active }), className)}
{...props}
ref={ref}
/>
)
);
export const SidebarAvatar = forwardRef<
HTMLDivElement,
css.SidebarAvatarBoxVariants &
css.SidebarBadgeBoxVariants & {
outlined?: boolean;
avatarChildren: ReactNode;
tooltip: ReactNode | string;
notificationBadge?: (badgeClassName: string) => ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
onContextMenu?: MouseEventHandler<HTMLButtonElement>;
}
>(
(
{
active,
hasCount,
outlined,
avatarChildren,
tooltip,
notificationBadge,
onClick,
onContextMenu,
},
ref
) => (
<SidebarAvatarBox active={active} ref={ref}>
<TooltipProvider
delay={0}
position="Right"
tooltip={
<Tooltip>
<Text size="T300">{tooltip}</Text>
</Tooltip>
}
>
{(avRef) => (
<Avatar
ref={avRef}
as="button"
onClick={onClick}
onContextMenu={onContextMenu}
style={{
border: outlined
? `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`
: undefined,
cursor: 'pointer',
}}
>
{avatarChildren}
</Avatar>
)}
</TooltipProvider>
{notificationBadge && (
<Box className={css.SidebarBadgeBox({ hasCount })}>
{notificationBadge(css.SidebarBadgeOutline)}
</Box>
)}
</SidebarAvatarBox>
)
);

View file

@ -1,21 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, Scroll } from 'folds';
type SidebarContentProps = {
scrollable: ReactNode;
sticky: ReactNode;
};
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
return (
<>
<Box direction="Column" grow="Yes">
<Scroll variant="Background" size="0">
{scrollable}
</Scroll>
</Box>
<Box direction="Column" shrink="No">
{sticky}
</Box>
</>
);
}

View file

@ -1,10 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import { as } from 'folds';
import * as css from './Sidebar.css';
export const SidebarStack = as<'div'>(
({ as: AsSidebarStack = 'div', className, ...props }, ref) => (
<AsSidebarStack className={classNames(css.SidebarStack, className)} {...props} ref={ref} />
)
);

View file

@ -1,13 +0,0 @@
import React from 'react';
import { Line, toRem } from 'folds';
export function SidebarStackSeparator() {
return (
<Line
role="separator"
style={{ width: toRem(24), margin: '0 auto' }}
variant="Background"
size="300"
/>
);
}

View file

@ -1,5 +0,0 @@
export * from './Sidebar';
export * from './SidebarAvatar';
export * from './SidebarContent';
export * from './SidebarStack';
export * from './SidebarStackSeparator';

View file

@ -1,37 +0,0 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
export const TextViewer = style([
DefaultReset,
{
height: '100%',
},
]);
export const TextViewerHeader = style([
DefaultReset,
{
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
},
]);
export const TextViewerContent = style([
DefaultReset,
{
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
overflow: 'hidden',
},
]);
export const TextViewerPre = style([
DefaultReset,
{
padding: config.space.S600,
whiteSpace: 'pre-wrap',
},
]);

View file

@ -1,65 +0,0 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { Suspense, lazy } from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds';
import { ErrorBoundary } from 'react-error-boundary';
import * as css from './TextViewer.css';
import { copyToClipboard } from '../../utils/dom';
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
export type TextViewerProps = {
name: string;
text: string;
langName: string;
requestClose: () => void;
};
export const TextViewer = as<'div', TextViewerProps>(
({ className, name, text, langName, requestClose, ...props }, ref) => {
const handleCopy = () => {
copyToClipboard(text);
};
return (
<Box
className={classNames(css.TextViewer, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.TextViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{name}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<Chip variant="Primary" radii="300" onClick={handleCopy}>
<Text size="B300">Copy All</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.TextViewerContent}
justifyContent="Center"
alignItems="Center"
>
<Scroll hideTrack variant="Background" visibility="Hover">
<Text as="pre" className={classNames(css.TextViewerPre, `language-${langName}`)}>
<ErrorBoundary fallback={<code>{text}</code>}>
<Suspense fallback={<code>{text}</code>}>
<ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
</Suspense>
</ErrorBoundary>
</Text>
</Scroll>
</Box>
</Box>
);
}
);

View file

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

View file

@ -1,49 +0,0 @@
import { keyframes } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, toRem } from 'folds';
const TypingDotAnime = keyframes({
to: {
opacity: '0.4',
transform: 'translateY(-15%)',
},
});
export const TypingDot = recipe({
base: [
DefaultReset,
{
display: 'inline-block',
backgroundColor: 'currentColor',
borderRadius: '50%',
transform: 'translateY(15%)',
animation: `${TypingDotAnime} 0.6s infinite alternate`,
},
],
variants: {
size: {
'300': {
width: toRem(4),
height: toRem(4),
},
'400': {
width: toRem(8),
height: toRem(8),
},
},
index: {
'0': {
animationDelay: '0s',
},
'1': {
animationDelay: '0.2s',
},
'2': {
animationDelay: '0.4s',
},
},
},
defaultVariants: {
size: '400',
},
});

View file

@ -1,22 +0,0 @@
import React from 'react';
import { Box, as, toRem } from 'folds';
import * as css from './TypingIndicator.css';
export type TypingIndicatorProps = {
size?: '300' | '400';
};
export const TypingIndicator = as<'div', TypingIndicatorProps>(({ size, style, ...props }, ref) => (
<Box
as="span"
alignItems="Center"
shrink="No"
style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
{...props}
ref={ref}
>
<span className={css.TypingDot({ size, index: '0' })} />
<span className={css.TypingDot({ size, index: '1' })} />
<span className={css.TypingDot({ size, index: '2' })} />
</Box>
));

View file

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

Some files were not shown because too many files have changed in this diff Show more