Merge branch 'dev' into dev

This commit is contained in:
Dylan 2022-07-16 23:49:34 +09:30 committed by GitHub
commit 05fc88a9fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1397 additions and 1309 deletions

View file

@ -1,4 +1,4 @@
<!-- Please read https://github.com/ajbura/cinny/CONTRIBUTING.md before submitting your pull request -->
<!-- Please read https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
### Description
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

View file

@ -12,6 +12,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3.0.2
- name: Setup node
uses: actions/setup-node@v3.4.1
with:
node-version: 17.9.0
- name: Build app
run: npm ci && npm run build
- name: Upload artifact

View file

@ -14,8 +14,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3.0.2
- name: Setup node
uses: actions/setup-node@v3.4.1
with:
node-version: 17.9.0
- name: Build and deploy to Netlify
uses: jsmrcaga/action-netlify-deploy@fb6a5f936a4b06a8f7793e69fc5a022ffe39807a
uses: jsmrcaga/action-netlify-deploy@53de32e559b0b3833615b9788c7a090cd2fddb03
with:
install_command: "npm ci"
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

View file

@ -11,6 +11,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3.0.2
- name: Setup node
uses: actions/setup-node@v3.4.1
with:
node-version: 17.9.0
- name: Build
run: |
npm ci
@ -45,8 +49,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3.0.2
- name: Setup node
uses: actions/setup-node@v3.4.1
with:
node-version: 17.9.0
- name: Build and deploy to Netlify
uses: jsmrcaga/action-netlify-deploy@fb6a5f936a4b06a8f7793e69fc5a022ffe39807a
uses: jsmrcaga/action-netlify-deploy@53de32e559b0b3833615b9788c7a090cd2fddb03
with:
install_command: "npm ci"
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

View file

@ -10,7 +10,7 @@ RUN npm run build
## App
FROM nginx:1.21.6-alpine
FROM nginx:1.23.0-alpine
COPY --from=builder /src/dist /app

View file

@ -1,12 +1,11 @@
{
"defaultHomeserver": 4,
"defaultHomeserver": 3,
"homeserverList": [
"converser.eu",
"envs.net",
"halogen.city",
"kde.org",
"matrix.org",
"chat.mozilla.org"
"mozilla.org"
],
"allowCustomHomeservers": true
}

2414
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -30,9 +30,11 @@
"i18next": "^21.8.9",
"i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^1.4.1",
"html-react-parser": "^2.0.0",
"katex": "^0.15.6",
"linkifyjs": "^2.1.9",
"matrix-js-sdk": "^18.0.0",
"linkify-html": "^4.0.0-beta.5",
"linkifyjs": "^4.0.0-beta.5",
"matrix-js-sdk": "^18.1.0",
"micromark": "^3.0.10",
"micromark-extension-gfm": "^2.0.1",
"micromark-extension-math": "^2.0.2",
@ -53,9 +55,9 @@
"twemoji": "^14.0.2"
},
"devDependencies": {
"@babel/core": "^7.18.2",
"@babel/preset-env": "^7.18.2",
"@babel/preset-react": "^7.17.12",
"@babel/core": "^7.18.6",
"@babel/preset-env": "^7.18.6",
"@babel/preset-react": "^7.18.6",
"assert": "^2.0.0",
"babel-loader": "^8.2.5",
"browserify-fs": "^1.0.0",
@ -65,27 +67,27 @@
"crypto-browserify": "^3.12.0",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^4.0.0",
"eslint": "^8.17.0",
"eslint": "^8.19.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.30.0",
"eslint-plugin-react-hooks": "^4.5.0",
"eslint-plugin-jsx-a11y": "^6.6.0",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"favicons": "^6.2.2",
"favicons-webpack-plugin": "^5.0.2",
"html-loader": "^3.1.0",
"html-loader": "^3.1.2",
"html-webpack-plugin": "^5.3.1",
"mini-css-extract-plugin": "^2.6.0",
"mini-css-extract-plugin": "^2.6.1",
"path-browserify": "^1.0.1",
"sass": "^1.52.2",
"sass-loader": "^13.0.0",
"sass": "^1.53.0",
"sass-loader": "^13.0.2",
"stream-browserify": "^3.0.0",
"style-loader": "^3.3.1",
"url": "^0.11.0",
"util": "^0.12.4",
"webpack": "^5.73.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.2",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.9.3",
"webpack-merge": "^5.7.3"
}
}

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.92896 3.51471L3.51474 4.92892L5.97515 7.38933C4.46742 8.5776 3.32116 9.93994 2.7 10.8C2.1 11.5 2.1 12.5 2.7 13.2C4 15 7.6 19 12 19C13.5709 19 15.0398 18.4902 16.3384 17.7526L19.0711 20.4853L20.4853 19.0711L4.92896 3.51471ZM4.2 12C4.68291 11.3561 5.85678 9.9637 7.39721 8.81139L9.29238 10.7066C9.10496 11.0982 9 11.5368 9 12C9 13.6569 10.3431 15 12 15C12.4632 15 12.9018 14.895 13.2934 14.7076L14.8573 16.2715C13.9566 16.7128 12.9896 17 12 17C8.4 17 5.1 13.2 4.2 12Z" fill="black"/>
<path d="M9.6226 5.37995L11.2906 7.04797C11.5254 7.01661 11.762 7 12 7C15.6 7 18.9 10.8 19.8 12C19.493 12.4094 18.9066 13.1213 18.1244 13.8817L19.5194 15.2768C20.2973 14.4974 20.9049 13.7471 21.3 13.2C21.9 12.5 21.9 11.5 21.3 10.8C20 9 16.4 5 12 5C11.1762 5 10.3805 5.14021 9.6226 5.37995Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 943 B

View file

@ -1,13 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<g>
<g>
<path d="M12,19c-4.4,0-8-4-9.3-5.8c-0.6-0.7-0.6-1.7,0-2.4C4,9,7.6,5,12,5s8,4,9.3,5.8c0.6,0.7,0.6,1.7,0,2.4C20,15,16.4,19,12,19
z M12,7c-3.6,0-6.9,3.8-7.8,5c0.9,1.2,4.2,5,7.8,5s6.9-3.8,7.8-5C18.9,10.8,15.6,7,12,7z"/>
</g>
<circle cx="12" cy="12" r="3"/>
</g>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 19C7.6 19 4 15 2.7 13.2C2.1 12.5 2.1 11.5 2.7 10.8C4 9 7.6 5 12 5C16.4 5 20 9 21.3 10.8C21.9 11.5 21.9 12.5 21.3 13.2C20 15 16.4 19 12 19ZM12 7C8.4 7 5.1 10.8 4.2 12C5.1 13.2 8.4 17 12 17C15.6 17 18.9 13.2 19.8 12C18.9 10.8 15.6 7 12 7Z" fill="black"/>
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 718 B

After

Width:  |  Height:  |  Size: 508 B

View file

@ -125,7 +125,7 @@ function RoomAliases({ roomId }) {
const loadLocalAliases = async () => {
let local = [];
try {
const result = await mx.unstableGetLocalAliases(roomId);
const result = await mx.getLocalAliases(roomId);
local = result.aliases.filter((alias) => !aliases.published.includes(alias));
} catch {
local = [];

View file

@ -242,12 +242,12 @@ function RoomPermissions({ roomId }) {
? permissions[permInfo.parent]?.[permKey]
: permissions[permKey];
if (!permValue) permValue = permInfo.default;
if (permValue === undefined) permValue = permInfo.default;
if (typeof permValue === 'number') {
powerLevel = permValue;
} else if (permKey === 'notifications') {
powerLevel = permValue.room || 50;
powerLevel = permValue.room ?? 50;
}
return (
<SettingTile

View file

@ -1,5 +1,6 @@
import emojisData from 'emojibase-data/en/compact.json';
import shortcodes from 'emojibase-data/en/shortcodes/joypixels.json';
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
const emojiGroups = [{
name: 'Smileys & people',
@ -52,7 +53,7 @@ function addToGroup(emoji) {
const emojis = [];
emojisData.forEach((emoji) => {
const myShortCodes = shortcodes[emoji.hexcode];
const myShortCodes = joypixels[emoji.hexcode] || emojibase[emoji.hexcode];
if (!myShortCodes) return;
const em = {
...emoji,

View file

@ -7,7 +7,7 @@ import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import * as roomActions from '../../../client/action/room';
import { selectRoom } from '../../../client/action/navigation';
import { hasDMWith } from '../../../util/matrixUtil';
import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';

View file

@ -12,7 +12,7 @@ import { selectRoom, openReusableContextMenu } from '../../../client/action/navi
import * as roomActions from '../../../client/action/room';
import {
getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith,
getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith, hasDevices
} from '../../../util/matrixUtil';
import { getEventCords } from '../../../util/common';
import colorMXID from '../../../util/colorMXID';
@ -209,7 +209,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
// Create new DM
try {
setIsCreatingDM(true);
await roomActions.createDM(userId);
await roomActions.createDM(userId, await hasDevices(userId));
} catch {
if (isMountedRef.current === false) return;
setIsCreatingDM(false);

View file

@ -62,23 +62,25 @@ function AppearanceSection() {
)}
content={<Text variant="b3">{t('Organisms.Settings.theme.follow_system.description')}</Text>}
/>
{!settings.useSystemTheme && (
<SettingTile
title={t('Organisms.Settings.theme.title')}
content={(
<SegmentedControls
selected={settings.getThemeIndex()}
selected={settings.useSystemTheme ? -1 : settings.getThemeIndex()}
segments={[
{ text: t('Organisms.Settings.theme.theme_light') },
{ text: t('Organisms.Settings.theme.theme_silver') },
{ text: t('Organisms.Settings.theme.theme_dark') },
{ text: t('Organisms.Settings.theme.theme_butter') },
]}
onSelect={(index) => settings.setTheme(index)}
onSelect={(index) => {
if (settings.useSystemTheme) toggleSystemTheme();
settings.setTheme(index);
updateState({});
}}
/>
)}
/>
)}
</div>
<div className="settings-appearance__card">
<MenuHeader>Room messages</MenuHeader>

View file

@ -21,6 +21,8 @@ import Avatar from '../../atoms/avatar/Avatar';
import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import EyeIC from '../../../../public/res/ic/outlined/eye.svg';
import EyeBlindIC from '../../../../public/res/ic/outlined/eye-blind.svg';
import CinnySvg from '../../../../public/res/svg/cinny.svg';
import SSOButtons from '../../molecules/sso-buttons/SSOButtons';
@ -54,11 +56,8 @@ function Homeserver({ onChange }) {
const setupHsConfig = async (servername) => {
setProcess({ isLoading: true, message: 'Looking for homeserver...' });
let baseUrl = null;
try {
baseUrl = await getBaseUrl(servername);
} catch (e) {
baseUrl = e.message;
}
if (searchingHs !== servername) return;
setProcess({ isLoading: true, message: `Connecting to ${baseUrl}...` });
const tempClient = auth.createTemporaryClient(baseUrl);
@ -97,7 +96,7 @@ function Homeserver({ onChange }) {
if (!hsList?.length > 0 || selectedHs < 0 || selectedHs >= hsList?.length) {
throw new Error();
}
setHs({ selected: hsList[selectedHs], list: hsList, allowCustom: allowCustom });
setHs({ selected: hsList[selectedHs], list: hsList, allowCustom });
} catch {
setHs({ selected: 'matrix.org', list: ['matrix.org'], allowCustom: true });
}
@ -114,8 +113,14 @@ function Homeserver({ onChange }) {
return (
<>
<div className="homeserver-form">
<Input name="homeserver" onChange={handleHsInput} value={hs?.selected} forwardRef={hsRef} label="Homeserver"
disabled={hs === null || !hs.allowCustom} />
<Input
name="homeserver"
onChange={handleHsInput}
value={hs?.selected}
forwardRef={hsRef}
label="Homeserver"
disabled={hs === null || !hs.allowCustom}
/>
<ContextMenu
placement="right"
content={(hideMenu) => (
@ -156,6 +161,7 @@ Homeserver.propTypes = {
function Login({ loginFlow, baseUrl }) {
const [typeIndex, setTypeIndex] = useState(0);
const [passVisible, setPassVisible] = useState(false);
const loginTypes = ['Username', 'Email'];
const isPassword = loginFlow?.filter((flow) => flow.type === 'm.login.password')[0];
const ssoProviders = loginFlow?.filter((flow) => flow.type === 'm.login.sso')[0];
@ -166,17 +172,23 @@ function Login({ loginFlow, baseUrl }) {
const validator = (values) => {
const errors = {};
if (typeIndex === 0 && values.username.length > 0 && values.username.indexOf(':') > -1) {
errors.username = 'Username must contain local-part only';
}
if (typeIndex === 1 && values.email.length > 0 && !isValidInput(values.email, EMAIL_REGEX)) {
errors.email = BAD_EMAIL_ERROR;
}
return errors;
};
const submitter = (values, actions) => auth.login(
baseUrl,
typeIndex === 0 ? normalizeUsername(values.username) : undefined,
const submitter = async (values, actions) => {
let userBaseUrl = baseUrl;
let { username } = values;
const mxIdMatch = username.match(/^@(.+):(.+\..+)$/);
if (typeIndex === 0 && mxIdMatch) {
[, username, userBaseUrl] = mxIdMatch;
userBaseUrl = await getBaseUrl(userBaseUrl);
}
return auth.login(
userBaseUrl,
typeIndex === 0 ? normalizeUsername(username) : undefined,
typeIndex === 1 ? values.email : undefined,
values.password,
).then(() => {
@ -191,6 +203,7 @@ function Login({ loginFlow, baseUrl }) {
});
actions.setSubmitting(false);
});
};
return (
<>
@ -236,7 +249,10 @@ function Login({ loginFlow, baseUrl }) {
{errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>}
{typeIndex === 1 && <Input values={values.email} name="email" onChange={handleChange} label="Email" type="email" required />}
{errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>}
<Input values={values.password} name="password" onChange={handleChange} label="Password" type="password" required />
<div className="auth-form__pass-eye-wrapper">
<Input values={values.password} name="password" onChange={handleChange} label="Password" type={passVisible ? 'text' : 'password'} required />
<IconButton onClick={() => setPassVisible(!passVisible)} src={passVisible ? EyeIC : EyeBlindIC} size="extra-small" />
</div>
{errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>}
{errors.other && <Text className="auth-form__error" variant="b3">{errors.other}</Text>}
<div className="auth-form__btns">
@ -269,6 +285,8 @@ let sid;
let clientSecret;
function Register({ registerInfo, loginFlow, baseUrl }) {
const [process, setProcess] = useState({});
const [passVisible, setPassVisible] = useState(false);
const [cPassVisible, setCPassVisible] = useState(false);
const formRef = useRef();
const ssoProviders = loginFlow?.filter((flow) => flow.type === 'm.login.sso')[0];
@ -319,6 +337,7 @@ function Register({ registerInfo, loginFlow, baseUrl }) {
if (!isAvail) {
actions.setErrors({ username: 'Username is already taken' });
actions.setSubmitting(false);
return;
}
if (isEmail && values.email.length > 0) {
const result = await auth.verifyEmail(baseUrl, values.email, clientSecret, 1);
@ -437,9 +456,15 @@ function Register({ registerInfo, loginFlow, baseUrl }) {
<form className="auth-form" ref={formRef} onSubmit={handleSubmit}>
<Input values={values.username} name="username" onChange={handleChange} label="Username" type="username" required />
{errors.username && <Text className="auth-form__error" variant="b3">{errors.username}</Text>}
<Input values={values.password} name="password" onChange={handleChange} label="Password" type="password" required />
<div className="auth-form__pass-eye-wrapper">
<Input values={values.password} name="password" onChange={handleChange} label="Password" type={passVisible ? 'text' : 'password'} required />
<IconButton onClick={() => setPassVisible(!passVisible)} src={passVisible ? EyeIC : EyeBlindIC} size="extra-small" />
</div>
{errors.password && <Text className="auth-form__error" variant="b3">{errors.password}</Text>}
<Input values={values.confirmPassword} name="confirmPassword" onChange={handleChange} label="Confirm password" type="password" required />
<div className="auth-form__pass-eye-wrapper">
<Input values={values.confirmPassword} name="confirmPassword" onChange={handleChange} label="Confirm password" type={cPassVisible ? 'text' : 'password'} required />
<IconButton onClick={() => setCPassVisible(!cPassVisible)} src={cPassVisible ? EyeIC : EyeBlindIC} size="extra-small" />
</div>
{errors.confirmPassword && <Text className="auth-form__error" variant="b3">{errors.confirmPassword}</Text>}
{isEmail && <Input values={values.email} name="email" onChange={handleChange} label={`Email${isEmailRequired ? '' : ' (optional)'}`} type="email" required={isEmailRequired} />}
{errors.email && <Text className="auth-form__error" variant="b3">{errors.email}</Text>}

View file

@ -97,7 +97,8 @@
}
.auth-form {
& > .input-container {
& > .input-container,
&__pass-eye-wrapper {
margin: var(--sp-tight) 0 var(--sp-ultra-tight);
}
@ -107,6 +108,20 @@
margin-top: calc(var(--sp-extra-loose) + var(--sp-tight));
}
&__pass-eye-wrapper {
position: relative;
& .ic-btn {
position: absolute;
@include dir.prop(right, 6px, unset);
@include dir.prop(left, unset, 6px );
bottom: 6px;
border-radius: 4px;
}
& input {
@include dir.side(padding, var(--sp-normal), 46px);
}
}
&__btns {
padding-top: var(--sp-loose);
margin-bottom: var(--sp-extra-loose);

View file

@ -48,31 +48,43 @@ class Settings extends EventEmitter {
return this.themes[this.themeIndex];
}
setTheme(themeIndex) {
const appBody = document.getElementById('appBody');
appBody.classList.remove('system-theme');
_clearTheme() {
document.body.classList.remove('system-theme');
this.themes.forEach((themeName) => {
if (themeName === '') return;
appBody.classList.remove(themeName);
document.body.classList.remove(themeName);
});
// If use system theme is enabled
// we will override current theme choice with system theme
if (this.useSystemTheme) {
appBody.classList.add('system-theme');
} else if (this.themes[themeIndex] !== '') {
appBody.classList.add(this.themes[themeIndex]);
}
setSettings('themeIndex', themeIndex);
applyTheme() {
this._clearTheme();
if (this.useSystemTheme) {
document.body.classList.add('system-theme');
} else if (this.themes[this.themeIndex]) {
document.body.classList.add(this.themes[this.themeIndex]);
}
}
setTheme(themeIndex) {
this.themeIndex = themeIndex;
setSettings('themeIndex', this.themeIndex);
this.applyTheme();
}
toggleUseSystemTheme() {
this.useSystemTheme = !this.useSystemTheme;
setSettings('useSystemTheme', this.useSystemTheme);
this.applyTheme();
this.emit(cons.events.settings.SYSTEM_THEME_TOGGLED, this.useSystemTheme);
}
getUseSystemTheme() {
if (typeof this.useSystemTheme === 'boolean') return this.useSystemTheme;
const settings = getSettings();
if (settings === null) return false;
if (typeof settings.useSystemTheme === 'undefined') return false;
if (settings === null) return true;
if (typeof settings.useSystemTheme === 'undefined') return true;
return settings.useSystemTheme;
}
@ -138,12 +150,7 @@ class Settings extends EventEmitter {
setter(action) {
const actions = {
[cons.actions.settings.TOGGLE_SYSTEM_THEME]: () => {
this.useSystemTheme = !this.useSystemTheme;
setSettings('useSystemTheme', this.useSystemTheme);
this.setTheme(this.themeIndex);
this.emit(cons.events.settings.SYSTEM_THEME_TOGGLED, this.useSystemTheme);
this.toggleUseSystemTheme();
},
[cons.actions.settings.TOGGLE_MARKDOWN]: () => {
this.isMarkdown = !this.isMarkdown;

View file

@ -7,7 +7,7 @@ import settings from './client/state/settings';
import App from './app/pages/App';
settings.setTheme(settings.getThemeIndex());
settings.applyTheme();
ReactDom.render(
<App />,

View file

@ -20,7 +20,7 @@ export async function getBaseUrl(servername) {
if (baseUrl === undefined) throw new Error();
return baseUrl;
} catch (e) {
throw new Error(`${protocol}${servername}`);
return `${protocol}${servername}`;
}
}
@ -204,3 +204,15 @@ export function getSSKeyInfo(key) {
return undefined;
}
}
export async function hasDevices(userId) {
const mx = initMatrix.matrixClient;
try {
const usersDeviceMap = await mx.downloadKeys([userId, mx.getUserId()]);
return Object.values(usersDeviceMap)
.every((userDevices) => (Object.keys(userDevices).length > 0));
} catch (e) {
console.error("Error determining if it's possible to encrypt to all users: ", e);
return false;
}
}

View file

@ -44,7 +44,7 @@ function transformSpanTag(tagName, attribs) {
}
function transformATag(tagName, attribs) {
const userLink = attribs.href.match(/^https?:\/\/matrix.to\/#\/(@.+:.+)/);
const userLink = decodeURIComponent(attribs.href).match(/^https?:\/\/matrix.to\/#\/(@.+:.+)/);
if (userLink !== null) {
// convert user link to pill
const userId = userLink[1];

View file

@ -1,7 +1,7 @@
/* eslint-disable import/prefer-default-export */
import React, { lazy, Suspense } from 'react';
import linkifyHtml from 'linkifyjs/html';
import linkifyHtml from 'linkify-html';
import parse from 'html-react-parser';
import twemoji from 'twemoji';
import { sanitizeText } from './sanitize';